✨ feat(vscode): 代码阅读预览把注释、说明、问 AI 回答里的 mermaid 代码块画成图

- markdown.ts:mermaid 代码块出占位(源码转义后记在 data-source,里面先放原样的代码块);带增删的说明、逐字生成中的回答仍是普通代码块
- useMermaidDiagrams:页面上有占位才按需加载 mermaid(strict、跟 VS Code 字体),按源码 + 配色缓存 SVG,一次只画一张,换主题重画;画不出来留代码块、下面一行原因
- 打包:Vite 的按需加载辅助函数单独成块,加构建检查禁止别的块 import 入口(入口带 ?v= 加载,被 import 会整页再跑一遍、第二次拿 VS Code API 报错)
- 主题明暗判断抽到 lib/vscodeTheme,看板的改动面板共用
This commit is contained in:
cruldra committed 2026-10-13 18:51:05 +11:00
1 parent bc84e74f29
commit 6dac457ed1
11 files changed
+1289 -24

No files matched your search

+856
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -19,6 +19,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"markdown-it": "^15.0.2", "markdown-it": "^15.0.2",
"mermaid": "^12.1.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwind-merge": "^3.6.0" "tailwind-merge": "^3.6.0"
+2
View File
@@ -28,6 +28,7 @@ import { createStore, useStore } from './store'
import { useChangeNav } from './useChangeNav' import { useChangeNav } from './useChangeNav'
import { useDefinitionLinks } from './useDefinitionLinks' import { useDefinitionLinks } from './useDefinitionLinks'
import { useDefinitionPeek } from './useDefinitionPeek' import { useDefinitionPeek } from './useDefinitionPeek'
import { useMermaidDiagrams } from './useMermaidDiagrams'
import { useOccurrences } from './useOccurrences' import { useOccurrences } from './useOccurrences'
import { useQa } from './useQa' import { useQa } from './useQa'
import { useRepoImages } from './useRepoImages' import { useRepoImages } from './useRepoImages'
@@ -105,6 +106,7 @@ export function App(): ReactElement | null {
const sticky = useStickySettings() const sticky = useStickySettings()
useRepoLinks() useRepoLinks()
useRepoImages() useRepoImages()
useMermaidDiagrams()
const nav = useChangeNav(stops, collapsed, expand, sticky) const nav = useChangeNav(stops, collapsed, expand, sticky)
const occurrencePage = useMemo((): OccurrencePage | undefined => doc const occurrencePage = useMemo((): OccurrencePage | undefined => doc
? { ? {
@@ -264,6 +264,28 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c
.cr-markdown .cr-repo-image a:hover { .cr-markdown .cr-repo-image a:hover {
text-decoration: none; text-decoration: none;
} }
/* ```mermaid 代码块画成的图:居中,宽不超过注释区,真放不下就横着滚;画不出来时留着代码块,下面一行原因用警告色 */
.cr-markdown .cr-mermaid {
margin: 0 0 6px;
overflow-x: auto;
}
.cr-markdown > .cr-mermaid:last-child {
margin-bottom: 0;
}
.cr-markdown .cr-mermaid > :is(pre, p) {
margin: 0;
}
.cr-markdown .cr-mermaid > svg {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
}
.cr-markdown .cr-mermaid > .cr-mermaid-error {
margin-top: 2px;
color: var(--vscode-editorWarning-foreground, var(--vscode-descriptionForeground));
font-size: 11.5px;
}
.cr-markdown blockquote { .cr-markdown blockquote {
padding: 0 8px; padding: 0 8px;
border-left: 3px solid var(--vscode-textBlockQuote-border); border-left: 3px solid var(--vscode-textBlockQuote-border);
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { renderMarkdown, renderMarkdownDiff } from './markdown' import { renderMarkdown, renderMarkdownDiff, renderMarkdownOnce } from './markdown'
import { repoImageInner } from './repoImage' import { repoImageInner } from './repoImage'
describe('renderMarkdown', () => { describe('renderMarkdown', () => {
@@ -124,3 +124,27 @@ describe('renderMarkdownDiff', () => {
expect(renderMarkdownDiff([{ text: '共 ' }, { text: '# 3', change: 'add' }])).toBe('<p>共 <ins class="cr-diff-word"># 3</ins></p>\n') expect(renderMarkdownDiff([{ text: '共 ' }, { text: '# 3', change: 'add' }])).toBe('<p>共 <ins class="cr-diff-word"># 3</ins></p>\n')
}) })
}) })
describe('mermaid 代码块', () => {
const source = 'flowchart TD\n A["读 <配置> & 写"] --> B{"成功?"}\n'
const escaped = 'flowchart TD\n A[&quot;读 &lt;配置&gt; &amp; 写&quot;] --&gt; B{&quot;成功?&quot;}\n'
const codeBlock = `<pre><code class="language-mermaid">${escaped}</code></pre>\n`
const placeholder = `<div class="cr-mermaid" data-source="${escaped}">${codeBlock}</div>\n`
it('出占位:转义后的源码记在 data-source,里面先放原样的代码块', () => {
expect(renderMarkdown(`说明\n\n\`\`\`mermaid\n${source}\`\`\``)).toBe(`<p>说明</p>\n${placeholder}`)
expect(renderMarkdown(`\`\`\`mermaid\n${source}\`\`\``, 'peek')).toBe(placeholder)
expect(renderMarkdown(`\`\`\`mermaid\n${source}\`\`\``, 'answer')).toBe(placeholder)
})
it('别的代码块照旧', () => {
expect(renderMarkdown('```ts\nload(\'a\')\n```')).toBe('<pre><code class="language-ts">load(\'a\')\n</code></pre>\n')
expect(renderMarkdown('```\nflowchart TD\n```')).toBe('<pre><code>flowchart TD\n</code></pre>\n')
expect(renderMarkdown('```mermaidx\nA\n```')).not.toContain('cr-mermaid')
})
it('带增删的说明、还在逐字长的回答里仍是普通代码块', () => {
expect(renderMarkdownDiff([{ text: `\`\`\`mermaid\n${source}\`\`\`` }])).toBe(codeBlock)
expect(renderMarkdownOnce(`\`\`\`mermaid\n${source}\`\`\``)).toBe(codeBlock)
})
})
+28 -10
View File
@@ -14,10 +14,11 @@ const md = new MarkdownIt({ html: false, linkify: true })
md.validateLink = url => /^(?:https?|mailto):/i.test(url) || parseRepoLink(url) !== undefined md.validateLink = url => /^(?:https?|mailto):/i.test(url) || parseRepoLink(url) !== undefined
/** /**
* 渲染时带的环境:symbolLinks 为真时认不带路径的引用(注释的 Markdown 才认,问 AI 的回答不认)。 * 渲染时带的环境:symbolLinks 为真时认不带路径的引用(注释的 Markdown 才认,问 AI 的回答不认);
* diagrams 为真时 ```mermaid 代码块出图的占位(源码完整、不夹增删记号的才出)。
* markdown-it 自己用 env.references 记参考式链接的定义,不能占这个名字。 * markdown-it 自己用 env.references 记参考式链接的定义,不能占这个名字。
*/ */
type RenderEnv = Record<string, unknown> & { symbolLinks?: boolean } type RenderEnv = Record<string, unknown> & { symbolLinks?: boolean, diagrams?: boolean }
/** 方括号里的标识符(字母、数字、下划线),点分开的几段:`Name`、`Class.member`。 */ /** 方括号里的标识符(字母、数字、下划线),点分开的几段:`Name`、`Class.member`。 */
const REFERENCE = /^\[([A-Z_$][\w$]*(?:\.[A-Z_$][\w$]*)*)\]/i const REFERENCE = /^\[([A-Z_$][\w$]*(?:\.[A-Z_$][\w$]*)*)\]/i
@@ -64,6 +65,20 @@ md.renderer.rules.image = (tokens, idx) => {
return `!<a href="${esc(src)}">${esc(alt)}</a>` return `!<a href="${esc(src)}">${esc(alt)}</a>`
} }
const renderFence = md.renderer.rules.fence!
/**
* ```mermaid 代码块先占位:转义后的源码记在 data-source,里面先放原样的代码块,[useMermaidDiagrams](vscode/webview-ui/src/codeReader/useMermaidDiagrams.ts#useMermaidDiagrams)
* 画好了换成图,画不出来就留着代码块。别的代码块照旧。
*/
md.renderer.rules.fence = (tokens, idx, options, env: RenderEnv | undefined, self) => {
const block = renderFence(tokens, idx, options, env, self)
const { info, content } = tokens[idx]
if (!env?.diagrams || info.trim().split(/\s+/)[0] !== 'mermaid')
return block
return `<div class="cr-mermaid" data-source="${md.utils.escapeHtml(content)}">${block}</div>\n`
}
/** 链接的标题(`[文字](网址 "标题")`)写成 data-tooltip,和页面上别的悬停提示走同一个提示框(components/ui/tooltip.tsx),不用原生 title。 */ /** 链接的标题(`[文字](网址 "标题")`)写成 data-tooltip,和页面上别的悬停提示走同一个提示框(components/ui/tooltip.tsx),不用原生 title。 */
md.core.ruler.push('link_title_tooltip', (state) => { md.core.ruler.push('link_title_tooltip', (state) => {
for (const block of state.tokens) { for (const block of state.tokens) {
@@ -88,19 +103,22 @@ const cache = new Map<string, string>()
*/ */
export type MarkdownKind = 'comment' | 'peek' | 'answer' export type MarkdownKind = 'comment' | 'peek' | 'answer'
function envOf(kind: MarkdownKind): RenderEnv { function envOf(kind: MarkdownKind, diagrams: boolean): RenderEnv {
return { symbolLinks: kind === 'comment' } return { symbolLinks: kind === 'comment', diagrams }
} }
/** 还在变的文字(逐字长出来的回答)→ HTML:每次都现渲染,不进缓存,免得半截的文字把缓存挤满。 */ /**
* 还在变的文字(逐字长出来的回答)→ HTML:每次都现渲染,不进缓存,免得半截的文字把缓存挤满。
* ```mermaid 代码块还没写完,照普通代码块显示,不出图。
*/
export function renderMarkdownOnce(text: string): string { export function renderMarkdownOnce(text: string): string {
return md.render(text, envOf('answer')) return md.render(text, envOf('answer', false))
} }
/** Markdown 原文 → HTML;同一段文字(同一种)只渲染一次。 */ /** Markdown 原文 → HTML;同一段文字(同一种)只渲染一次。```mermaid 代码块出图的占位,所以缓存的都是带占位的。 */
export function renderMarkdown(text: string, kind: MarkdownKind = 'comment'): string { export function renderMarkdown(text: string, kind: MarkdownKind = 'comment'): string {
const key = `${kind}\u0000${text}` const key = `${kind}\u0000${text}`
const html = cache.get(key) ?? md.render(text, envOf(kind)) const html = cache.get(key) ?? md.render(text, envOf(kind, true))
cache.delete(key) cache.delete(key)
cache.set(key, html) cache.set(key, html)
if (cache.size > CACHE_SIZE) if (cache.size > CACHE_SIZE)
@@ -119,7 +137,7 @@ const BLOCK_LEAD = /^\s*(?:(?:[\-*+>]|\d+[.)]|#{1,6})\s+)*/
/** /**
* 带增删的 Markdown 说明 → HTML:照常排版(同注释,认不带路径的引用),新加的字包进 <ins>、删掉的包进 <del>(样式见 .cr-diff-word)。 * 带增删的 Markdown 说明 → HTML:照常排版(同注释,认不带路径的引用),新加的字包进 <ins>、删掉的包进 <del>(样式见 .cr-diff-word)。
* 一截跨了几行时逐行包,记号不跨段落、列表项。 * 一截跨了几行时逐行包,记号不跨段落、列表项。```mermaid 代码块的源码里夹着记号,照普通代码块显示,不出图。
*/ */
export function renderMarkdownDiff(parts: readonly { text: string, change?: 'add' | 'del' }[]): string { export function renderMarkdownDiff(parts: readonly { text: string, change?: 'add' | 'del' }[]): string {
let text = '' let text = ''
@@ -136,7 +154,7 @@ export function renderMarkdownDiff(parts: readonly { text: string, change?: 'add
return rest ? `${lead}${open}${rest}${close}` : line return rest ? `${lead}${open}${rest}${close}` : line
}).join('\n') }).join('\n')
} }
let html = md.render(text, envOf('comment')) let html = md.render(text, envOf('comment', false))
for (const [open, close, openTag, closeTag] of Object.values(MARKS)) for (const [open, close, openTag, closeTag] of Object.values(MARKS))
html = html.replaceAll(open, openTag).replaceAll(close, closeTag) html = html.replaceAll(open, openTag).replaceAll(close, closeTag)
return html return html
@@ -0,0 +1,163 @@
// @vitest-environment happy-dom
import type { Root } from 'react-dom/client'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderMarkdown } from './markdown'
/** 假的 mermaid:记下加载了几次、按哪个主题初始化、画了什么;源码里有 `坏` 就当解析失败。 */
const fake = vi.hoisted(() => ({
imports: 0,
theme: '',
running: 0,
mostRunning: 0,
/** 每张图画之前等它,测并发时卡住 */
gate: Promise.resolve(),
initialize: vi.fn((config: { theme: string }) => {
fake.theme = config.theme
}),
render: vi.fn(async (id: string, text: string) => {
fake.running += 1
fake.mostRunning = Math.max(fake.mostRunning, fake.running)
await fake.gate
fake.running -= 1
if (text.includes('坏'))
throw new Error('Parse error on line 2:\n坏\n^\nExpecting \'NODE\', got \'EOF\'')
return { svg: `<svg id="${id}" data-theme="${fake.theme}"><text>${text.trim()}</text></svg>` }
}),
}))
/** 加载一次 mermaid 记一次 */
function mermaidModule(): { default: Pick<typeof fake, 'initialize' | 'render'> } {
fake.imports += 1
return { default: { initialize: fake.initialize, render: fake.render } }
}
let root: Root
let page: HTMLDivElement
/** 挂上只用这个 hook 的组件;每个测试重新加载模块、重新登记假的 mermaid,mermaid 有没有被加载各算各的 */
async function mount(): Promise<void> {
vi.resetModules()
vi.doMock('mermaid', mermaidModule)
const { useMermaidDiagrams } = await import('./useMermaidDiagrams')
function Diagrams(): null {
useMermaidDiagrams()
return null
}
const host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
await act(async () => root.render(<Diagrams />))
}
/** 页面上加一段注释的 Markdown,返回它的容器 */
function addMarkdown(markdown: string): HTMLDivElement {
const div = document.createElement('div')
div.className = 'cr-markdown'
div.innerHTML = renderMarkdown(markdown)
page.append(div)
return div
}
const flow = (text: string): string => `\`\`\`mermaid\nflowchart TD\n ${text}\n\`\`\``
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
document.body.className = 'vscode-dark'
page = document.createElement('div')
document.body.append(page)
fake.gate = Promise.resolve()
fake.running = 0
fake.mostRunning = 0
fake.render.mockClear()
fake.initialize.mockClear()
})
afterEach(async () => {
await act(async () => root.unmount())
document.body.replaceChildren()
vi.unstubAllGlobals()
})
describe('useMermaidDiagrams', () => {
it('页面上没有 mermaid 占位就不加载 mermaid', async () => {
const before = fake.imports
await mount()
addMarkdown('```ts\nload()\n```\n\n说明')
await new Promise(resolve => setTimeout(resolve, 150))
expect(fake.imports).toBe(before)
addMarkdown(flow('A["开始"] --> B["结束"]'))
await vi.waitFor(() => expect(page.querySelector('.cr-mermaid svg')).not.toBeNull())
expect(fake.imports).toBe(before + 1)
})
it('只画占位:代码块换成图,别的代码块不动;初始化不跑页面脚本、深色主题', async () => {
await mount()
const other = addMarkdown('```ts\nload()\n```')
const diagram = addMarkdown(flow('A["读取会话"] --> B{"是本人的?"}'))
await vi.waitFor(() => expect(diagram.querySelector('.cr-mermaid svg')).not.toBeNull())
expect(diagram.querySelector('.cr-mermaid pre')).toBeNull()
expect(diagram.querySelector('svg')!.dataset.theme).toBe('dark')
expect(other.innerHTML).toBe('<pre><code class="language-ts">load()\n</code></pre>\n')
expect(fake.render).toHaveBeenCalledTimes(1)
expect(fake.render.mock.calls[0][1]).toBe('flowchart TD\n A["读取会话"] --> B{"是本人的?"}\n')
expect(fake.initialize).toHaveBeenCalledWith(expect.objectContaining({ startOnLoad: false, securityLevel: 'strict', theme: 'dark' }))
})
it('同样的源码、同样的主题只画一次:两处一起画,卡片重新挂上直接用画好的', async () => {
await mount()
const first = addMarkdown(flow('A --> B'))
const second = addMarkdown(flow('A --> B'))
await vi.waitFor(() => expect(second.querySelector('.cr-mermaid svg')).not.toBeNull())
expect(first.querySelector('.cr-mermaid svg')).not.toBeNull()
expect(fake.render).toHaveBeenCalledTimes(1)
first.remove()
second.remove()
const again = addMarkdown(flow('A --> B'))
await vi.waitFor(() => expect(again.querySelector('.cr-mermaid svg')).not.toBeNull())
expect(fake.render).toHaveBeenCalledTimes(1)
})
it('主题变了按新主题重画;换回来用画过的', async () => {
await mount()
const diagram = addMarkdown(flow('A --> B'))
await vi.waitFor(() => expect(diagram.querySelector('svg')?.dataset.theme).toBe('dark'))
document.body.className = 'vscode-light'
await vi.waitFor(() => expect(diagram.querySelector('svg')?.dataset.theme).toBe('default'))
expect(fake.render).toHaveBeenCalledTimes(2)
document.body.className = 'vscode-high-contrast'
await vi.waitFor(() => expect(diagram.querySelector('svg')?.dataset.theme).toBe('dark'))
expect(fake.render).toHaveBeenCalledTimes(2)
})
it('画不出来:留着代码块,下面写一行简短原因,悬停看全文', async () => {
await mount()
const diagram = addMarkdown(flow('坏 -->'))
await vi.waitFor(() => expect(diagram.querySelector('.cr-mermaid-error')).not.toBeNull())
expect(diagram.querySelector('.cr-mermaid pre code')!.textContent).toBe('flowchart TD\n 坏 -->\n')
expect(diagram.querySelector('svg')).toBeNull()
const error = diagram.querySelector<HTMLElement>('.cr-mermaid-error')!
expect(error.textContent).toBe('图画不出来:Parse error on line 2:')
expect(error.dataset.tooltip).toContain('Expecting \'NODE\', got \'EOF\'')
})
it('一次只画一张', async () => {
let open = (): void => {}
fake.gate = new Promise(resolve => open = resolve)
await mount()
const diagrams = [addMarkdown(flow('A --> B')), addMarkdown(flow('C --> D')), addMarkdown(flow('E --> F'))]
await vi.waitFor(() => expect(fake.render).toHaveBeenCalledTimes(1))
await new Promise(resolve => setTimeout(resolve, 100))
expect(fake.render).toHaveBeenCalledTimes(1)
open()
await vi.waitFor(() => expect(diagrams.every(diagram => diagram.querySelector('svg'))).toBe(true))
expect(fake.render).toHaveBeenCalledTimes(3)
expect(fake.mostRunning).toBe(1)
})
})
@@ -0,0 +1,152 @@
import type { Mermaid } from 'mermaid'
import { useEffect, useState } from 'react'
import { vscodeThemeKind } from '@/lib/vscodeTheme'
/** mermaid 的配色:VS Code 浅色主题用 `default`,深色和高对比用 `dark`。 */
type DiagramTheme = 'default' | 'dark'
/** 一张图画出来的样子:SVG,或者画不出来的原因。 */
type Drawn = { svg: string } | { error: string }
/** 还没按当前配色画好的一张图。 */
interface Pending {
source: string
theme: DiagramTheme
}
/** 画好的图最多记几张,最久没用的先丢。 */
const CACHE_SIZE = 100
function currentTheme(): DiagramTheme {
return vscodeThemeKind() === 'light' ? 'default' : 'dark'
}
/** 页面正文的字体(VS Code 设置里的界面字体),让图里的字和注释一样。没设就用 mermaid 自己的。 */
function pageFont(): string | undefined {
return getComputedStyle(document.body).getPropertyValue('--vscode-font-family').trim() || undefined
}
/**
* 页面上 ```mermaid 代码块的占位(见 [Markdown 排版](vscode/webview-ui/src/codeReader/markdown.ts))画成图。
*
* 按源码 + 配色记下画好的图,卡片重新挂上、滚回来直接用。一次只画一张;画的时候页面或主题又变了,
* 画完这张按页面现在的样子接着画。第一次遇到占位才加载 mermaid。
*/
class MermaidDiagrams {
private readonly drawn = new Map<string, Drawn>()
/** 占位里原来的代码块:换成图以后画不出来了,放回去 */
private readonly codeBlocks = new WeakMap<HTMLElement, Node[]>()
private mermaid?: Promise<Mermaid>
/** mermaid 按哪种配色初始化过 */
private initialized?: DiagramTheme
private drawing = false
private count = 0
/** 把页面上还没按当前配色画好的占位都画上。正在画就不另起一轮,正在跑的那一轮画完会接着看页面。 */
async draw(): Promise<void> {
if (this.drawing)
return
this.drawing = true
try {
for (let next = this.paint(); next; next = this.paint())
this.remember(next, await this.render(next))
}
finally {
this.drawing = false
}
}
/** 画过的图直接换上;返回第一张还没画过的,都画过了返回 `undefined`。 */
private paint(): Pending | undefined {
const theme = currentTheme()
let pending: Pending | undefined
for (const node of document.querySelectorAll<HTMLElement>('.cr-markdown .cr-mermaid')) {
const source = node.dataset.source
if (source === undefined || node.dataset.drawn === theme)
continue
const drawn = this.recall({ source, theme })
if (drawn)
this.show(node, drawn, theme)
else
pending ??= { source, theme }
}
return pending
}
/** 画好了把代码块换成图;画不出来留着代码块,下面一行写原因的第一行,悬停看全文。 */
private show(node: HTMLElement, drawn: Drawn, theme: DiagramTheme): void {
const codeBlock = this.codeBlocks.get(node) ?? [...node.childNodes]
this.codeBlocks.set(node, codeBlock)
if ('svg' in drawn) {
node.innerHTML = drawn.svg
}
else {
const line = document.createElement('p')
line.className = 'cr-mermaid-error'
line.textContent = `图画不出来:${drawn.error.split('\n')[0]}`
if (drawn.error.includes('\n'))
line.dataset.tooltip = drawn.error
node.replaceChildren(...codeBlock, line)
}
node.dataset.drawn = theme
}
private async render({ source, theme }: Pending): Promise<Drawn> {
try {
const mermaid = await (this.mermaid ??= import('mermaid').then(module => module.default))
if (this.initialized !== theme) {
// strict:图里的文字不当 HTML、点击不跑脚本;出错不往页面上画 mermaid 自己的报错图,由 [show] 写原因
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', suppressErrorRendering: true, theme, fontFamily: pageFont() })
this.initialized = theme
}
this.count += 1
const { svg } = await mermaid.render(`cr-mermaid-${this.count}`, source)
return { svg }
}
catch (error) {
return { error: error instanceof Error ? error.message : String(error) }
}
}
private recall(diagram: Pending): Drawn | undefined {
const key = `${diagram.theme}\u0000${diagram.source}`
const drawn = this.drawn.get(key)
if (drawn) {
this.drawn.delete(key)
this.drawn.set(key, drawn)
}
return drawn
}
private remember(diagram: Pending, drawn: Drawn): void {
this.drawn.set(`${diagram.theme}\u0000${diagram.source}`, drawn)
if (this.drawn.size > CACHE_SIZE)
this.drawn.delete(this.drawn.keys().next().value!)
}
}
/**
* 注释、说明、问 AI 的回答里的 ```mermaid 代码块画成图:页面内容变了看有没有新的占位,VS Code 换了主题按新配色重画。
* 页面上没有占位时只多一次查找,不加载 mermaid。
*/
export function useMermaidDiagrams(): void {
const [diagrams] = useState(() => new MermaidDiagrams())
useEffect(() => {
let timer = 0
const scan = (): void => {
window.clearTimeout(timer)
timer = window.setTimeout(() => void diagrams.draw(), 50)
}
const content = new MutationObserver(scan)
content.observe(document.body, { childList: true, subtree: true })
const theme = new MutationObserver(scan)
theme.observe(document.body, { attributes: true, attributeFilter: ['class'] })
scan()
return () => {
content.disconnect()
theme.disconnect()
window.clearTimeout(timer)
}
}, [diagrams])
}
@@ -14,6 +14,7 @@ import { generateDiffFile } from '@git-diff-view/file'
import { DiffModeEnum, DiffView } from '@git-diff-view/react' import { DiffModeEnum, DiffView } from '@git-diff-view/react'
import { Check, Loader2, Sparkles } from 'lucide-react' import { Check, Loader2, Sparkles } from 'lucide-react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { vscodeThemeKind } from '../../lib/vscodeTheme'
import { statusBadge, statusColorVar } from './fileTreeModel' import { statusBadge, statusColorVar } from './fileTreeModel'
import '@git-diff-view/react/styles/diff-view.css' import '@git-diff-view/react/styles/diff-view.css'
@@ -40,16 +41,6 @@ interface FileDiffPaneProps {
onModeChange: (mode: DiffModeEnum) => void onModeChange: (mode: DiffModeEnum) => void
} }
/** 读当前 VS Code 主题明暗:body 带 vscode-light / vscode-high-contrast-light 即浅色,否则深色。 */
function readThemeKind(): 'light' | 'dark' {
if (typeof document === 'undefined')
return 'dark'
const cl = document.body.classList
if (cl.contains('vscode-light') || cl.contains('vscode-high-contrast-light'))
return 'light'
return 'dark'
}
function Centered({ children }: { children: React.ReactNode }) { function Centered({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]"> <div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
@@ -70,14 +61,14 @@ export function FileDiffPane({
mode, mode,
onModeChange, onModeChange,
}: FileDiffPaneProps) { }: FileDiffPaneProps) {
const [theme, setTheme] = useState<'light' | 'dark'>(() => readThemeKind()) const [theme, setTheme] = useState<'light' | 'dark'>(() => vscodeThemeKind())
const scrollRef = useRef<HTMLDivElement | null>(null) const scrollRef = useRef<HTMLDivElement | null>(null)
// 按文件路径记 scrollTop;随组件生命周期(key=issueNumber)重置。 // 按文件路径记 scrollTop;随组件生命周期(key=issueNumber)重置。
const scrollByPath = useRef<Record<string, number>>({}) const scrollByPath = useRef<Record<string, number>>({})
// 主题随 VS Code 实时切换。 // 主题随 VS Code 实时切换。
useEffect(() => { useEffect(() => {
const obs = new MutationObserver(() => setTheme(readThemeKind())) const obs = new MutationObserver(() => setTheme(vscodeThemeKind()))
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] }) obs.observe(document.body, { attributes: true, attributeFilter: ['class'] })
return () => obs.disconnect() return () => obs.disconnect()
}, []) }, [])
+10
View File
@@ -0,0 +1,10 @@
/**
* 当前 VS Code 主题是浅色还是深色:webview 的 body 带 `vscode-light` 或 `vscode-high-contrast-light` 是浅色,
* 别的(`vscode-dark`、`vscode-high-contrast`)都算深色。主题切换时 VS Code 改的是 body 的 class。
*/
export function vscodeThemeKind(): 'light' | 'dark' {
if (typeof document === 'undefined')
return 'dark'
const classes = document.body.classList
return classes.contains('vscode-light') || classes.contains('vscode-high-contrast-light') ? 'light' : 'dark'
}
+27 -1
View File
@@ -1,10 +1,33 @@
import type { Plugin } from 'vite'
import path from 'node:path' import path from 'node:path'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
/**
* 打包出的块里有 import 入口的就报错,免得到了 VS Code 里页面整个跑两遍。
*
* 入口由加载方加上 `?v=` 加载(见 [webview 页面](vscode/src/webview/html.ts#webviewHtml)),别的块按不带 `?v=` 的地址
* import 入口,浏览器当它是另一个模块,再加载、再跑一遍。
*/
function noEntryImports(): Plugin {
return {
name: 'no-entry-imports',
enforce: 'post',
generateBundle(_options, bundle) {
const chunks = Object.values(bundle).filter(file => file.type === 'chunk')
const entries = new Set(chunks.filter(chunk => chunk.isEntry).map(chunk => chunk.fileName))
for (const chunk of chunks) {
const imported = [...chunk.imports, ...chunk.dynamicImports].filter(file => entries.has(file))
if (imported.length > 0)
this.error(`${chunk.fileName} import 了入口 ${imported.join('、')}:入口带 ?v= 加载,这样会再跑一遍。把它们共用的模块用 manualChunks 拆进带哈希的块`)
}
},
}
}
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss(), noEntryImports()],
base: './', base: './',
build: { build: {
outDir: '../dist/webview-ui', outDir: '../dist/webview-ui',
@@ -23,6 +46,9 @@ export default defineConfig({
// 入口由加载方按 mtime 加 ?v= 防缓存,入口 import 的共享块(React 等)没有这一步,只能靠文件名带内容哈希 // 入口由加载方按 mtime 加 ?v= 防缓存,入口 import 的共享块(React 等)没有这一步,只能靠文件名带内容哈希
chunkFileNames: 'assets/[name]-[hash].js', chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name].[ext]', assetFileNames: 'assets/[name].[ext]',
// Vite 包在 import() 外面的辅助函数默认打进入口;按需加载的块(mermaid)里也有 import(),要用它就得 import 入口,
// 所以单独放一块
manualChunks: id => id.includes('vite/preload-helper') ? 'preload-helper' : undefined,
}, },
}, },
}, },