✨ 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:
1 parent
bc84e74f29
commit
6dac457ed1
11 files changed
+1289
-24
No files matched your search
Generated
+856
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
|||||||
@@ -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["读 <配置> & 写"] --> B{"成功?"}\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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user