🐛 fix(vscode): 代码阅读的 Markdown 预览固定同一份 HTML,悬停链接不再闪

This commit is contained in:
cruldra committed 2026-10-08 17:35:07 +08:00
1 parent 079657da37
commit 3abc87dbef
5 files changed
+256 -10

No files matched your search

+1
View File
@@ -539,6 +539,7 @@
"concurrently": "^9.1.0",
"esbuild": "^0.25.0",
"eslint": "^9.39.2",
"happy-dom": "^20.14.5",
"ovsx": "^0.10.7",
"tree-sitter-wasm": "^2.0.2",
"typescript": "^5.9.3",
+74 -7
View File
@@ -37,7 +37,7 @@ importers:
devDependencies:
'@antfu/eslint-config':
specifier: ^6.7.3
version: 6.7.3(@vue/compiler-sfc@3.4.27)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2))
version: 6.7.3(@vue/compiler-sfc@3.4.27)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.16(@types/node@25.0.3)(happy-dom@20.14.5)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2))
'@types/ali-oss':
specifier: ^6.23.3
version: 6.23.3
@@ -65,6 +65,9 @@ importers:
eslint:
specifier: ^9.39.2
version: 9.39.2(jiti@2.6.1)
happy-dom:
specifier: ^20.14.5
version: 20.14.5
ovsx:
specifier: ^0.10.7
version: 0.10.7
@@ -76,7 +79,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.0.16
version: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2)
version: 4.0.16(@types/node@25.0.3)(happy-dom@20.14.5)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2)
webview-ui:
dependencies:
@@ -1301,6 +1304,12 @@ packages:
'@types/vscode@1.97.0':
resolution: {integrity: sha512-ueE73loeOTe7olaVyqP9mrRI54kVPJifUPjblZo9fYcv1CuVLPOEKEkqW0GkqPC454+nCEoigLWnC2Pp7prZ9w==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/ws@8.18.2':
resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==}
'@typescript-eslint/eslint-plugin@8.52.0':
resolution: {integrity: sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1626,6 +1635,10 @@ packages:
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-image-size@0.6.4:
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
engines: {node: '>=4.0'}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
@@ -1948,6 +1961,10 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
entities@8.1.0:
resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
engines: {node: '>=20.19.0'}
@@ -2382,6 +2399,10 @@ packages:
engines: {node: '>=0.4.7'}
hasBin: true
happy-dom@20.14.5:
resolution: {integrity: sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==}
engines: {node: '>=20.0.0'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -3807,6 +3828,10 @@ packages:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -3835,6 +3860,18 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.22.0:
resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xml-name-validator@4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
@@ -3904,7 +3941,7 @@ snapshots:
'@aashutoshrathi/word-wrap@1.2.6': {}
'@antfu/eslint-config@6.7.3(@vue/compiler-sfc@3.4.27)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2))':
'@antfu/eslint-config@6.7.3(@vue/compiler-sfc@3.4.27)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.16(@types/node@25.0.3)(happy-dom@20.14.5)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2))':
dependencies:
'@antfu/install-pkg': 1.1.0
'@clack/prompts': 0.11.0
@@ -3913,7 +3950,7 @@ snapshots:
'@stylistic/eslint-plugin': 5.6.1(eslint@9.39.2(jiti@2.6.1))
'@typescript-eslint/eslint-plugin': 8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@vitest/eslint-plugin': 1.6.5(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2))
'@vitest/eslint-plugin': 1.6.5(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.16(@types/node@25.0.3)(happy-dom@20.14.5)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2))
ansis: 4.2.0
cac: 6.7.14
eslint: 9.39.2(jiti@2.6.1)
@@ -4953,6 +4990,12 @@ snapshots:
'@types/vscode@1.97.0': {}
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.2':
dependencies:
'@types/node': 25.0.3
'@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -5056,14 +5099,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/eslint-plugin@1.6.5(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2))':
'@vitest/eslint-plugin@1.6.5(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.16(@types/node@25.0.3)(happy-dom@20.14.5)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2))':
dependencies:
'@typescript-eslint/scope-manager': 8.52.0
'@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
optionalDependencies:
typescript: 5.9.3
vitest: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2)
vitest: 4.0.16(@types/node@25.0.3)(happy-dom@20.14.5)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
@@ -5376,6 +5419,10 @@ snapshots:
buffer-equal-constant-time@1.0.1(patch_hash=beceab8aa83d254a64c495a1832ac06b1829a43e89426e612563a08fed744b51): {}
buffer-image-size@0.6.4:
dependencies:
'@types/node': 25.0.3
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
@@ -5681,6 +5728,8 @@ snapshots:
entities@4.5.0: {}
entities@7.0.1: {}
entities@8.1.0: {}
environment@1.1.0: {}
@@ -6226,6 +6275,19 @@ snapshots:
optionalDependencies:
uglify-js: 3.19.3
happy-dom@20.14.5:
dependencies:
'@types/node': 25.0.3
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.2
buffer-image-size: 0.6.4
entities: 7.0.1
whatwg-mimetype: 3.0.0
ws: 8.22.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
has-flag@4.0.0: {}
has-property-descriptors@1.0.1:
@@ -7767,7 +7829,7 @@ snapshots:
tsx: 4.19.2
yaml: 2.8.2
vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2):
vitest@4.0.16(@types/node@25.0.3)(happy-dom@20.14.5)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@6.4.2(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2))
@@ -7791,6 +7853,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.0.3
happy-dom: 20.14.5
transitivePeerDependencies:
- jiti
- less
@@ -7816,6 +7879,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
whatwg-mimetype@3.0.0: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -7845,6 +7910,8 @@ snapshots:
wrappy@1.0.2: {}
ws@8.22.0: {}
xml-name-validator@4.0.0: {}
xml2js@0.5.0:
@@ -3,6 +3,7 @@ import type { InlinePart, TextDiff } from './types'
import { cn } from '@/lib/utils'
import { renderMarkdownDiff } from './markdown'
import { SegmentText } from './SegmentText'
import { MarkdownPreview } from './TextPreview'
/**
* 着色的词里的增删(同一行的值改了):没变的照常,删掉的一截红底划掉(旧值,不能跳定义),新加的一截绿底,
@@ -25,7 +26,7 @@ export function DiffInline({ parts }: { parts: InlinePart[] }): ReactElement {
/** 预览里一段带增删的文字:着色的词逐截标(DiffInline),Markdown 说明照常排版、增删的字按词 / 字标(同 TextPreview 的排版)。 */
export function DiffText({ diff, className }: { diff: TextDiff, className?: string }): ReactElement {
if ('markdown' in diff)
return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={{ __html: renderMarkdownDiff(diff.markdown) }} />
return <MarkdownPreview html={renderMarkdownDiff(diff.markdown)} className={className} />
return (
<p className={cn('cr-code', className)}>
<DiffInline parts={diff.inline} />
@@ -0,0 +1,172 @@
// @vitest-environment happy-dom
import type { ReactElement } from 'react'
import type { Root } from 'react-dom/client'
import type { ShownPeek } from './definitionPeek'
import type { DefinitionPeek } from './types'
import { act, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DefinitionPeeks, linkPeekSource } from './definitionPeek'
import { DiffText } from './DiffText'
import { MarkdownKindContext, TextPreview } from './TextPreview'
let container: HTMLDivElement
let root: Root
let peeks: DefinitionPeeks<HTMLAnchorElement> | undefined
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(async () => {
peeks?.dispose()
peeks = undefined
await act(async () => root.unmount())
container.remove()
vi.useRealTimers()
vi.unstubAllGlobals()
})
async function render(element: ReactElement): Promise<void> {
await act(async () => root.render(element))
}
function anchor(): HTMLAnchorElement {
const link = container.querySelector('a')
if (!link)
throw new Error('没有渲染符号链接')
return link
}
describe.each(['TextPreview', 'DiffText'] as const)('%s 的 Markdown 页面元素', (component) => {
function preview(markdown: string): ReactElement {
return component === 'TextPreview'
? <TextPreview text={{ markdown }} className="custom-preview" />
: <DiffText diff={{ markdown: [{ text: markdown }] }} className="custom-preview" />
}
it('父级无关状态更新、传入等值新对象时,保留原链接且仍在页面中', async () => {
function Parent(): ReactElement {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(value => value + 1)}>{count}</button>
{preview('[AsyncOpenAI]')}
</>
)
}
await render(<Parent />)
const original = anchor()
for (let count = 1; count <= 3; count++) {
await act(async () => container.querySelector('button')!.click())
expect(container.querySelector('button')!.textContent).toBe(String(count))
expect(anchor() === original).toBe(true)
expect(original.isConnected).toBe(true)
}
expect(container.querySelector('.cr-markdown.custom-preview')).not.toBeNull()
})
it('正文变化时更新链接和内容', async () => {
await render(preview('[AsyncOpenAI]'))
const original = anchor()
await render(preview('[Client] 新说明'))
expect(anchor().textContent).toBe('Client')
expect(anchor().getAttribute('href')).toBe('symbol:Client')
expect(container.textContent).toContain('新说明')
expect(original.isConnected).toBe(false)
})
it('空 Markdown 与着色代码片段可以来回切换', async () => {
const empty = (): ReactElement => component === 'TextPreview'
? <TextPreview text={{ markdown: '' }} />
: <DiffText diff={{ markdown: [] }} />
await render(preview('[Client]'))
await render(empty())
expect(container.querySelector('.cr-markdown')?.innerHTML).toBe('')
expect(container.querySelector('a')).toBeNull()
await render(component === 'TextPreview'
? <TextPreview text={[{ text: 'client', role: 'variable' }]} />
: <DiffText diff={{ inline: [{ segments: [{ text: 'client', role: 'variable' }], change: 'add' }] }} />)
expect(container.querySelector('.cr-code .cr-role-variable')?.textContent).toBe('client')
expect(container.querySelector('.cr-markdown')).toBeNull()
if (component === 'DiffText')
expect(container.querySelector('[data-change="add"]')?.textContent).toBe('client')
await render(empty())
expect(container.querySelector('.cr-markdown')?.innerHTML).toBe('')
expect(container.querySelector('.cr-code')).toBeNull()
await render(preview('[Client]'))
expect(anchor().textContent).toBe('Client')
})
it('显示定义预览引起父级更新后,继续指向同一链接不重复请求也不关闭', async () => {
vi.useFakeTimers()
const post = vi.fn()
function Parent(): ReactElement {
const [shown, setShown] = useState<ShownPeek<HTMLAnchorElement>>()
const [controller] = useState(() => new DefinitionPeeks<HTMLAnchorElement>(post, setShown))
peeks = controller
return (
<>
{preview('[AsyncOpenAI]')}
<output>{shown ? '预览已显示' : '预览未显示'}</output>
</>
)
}
await render(<Parent />)
const controller = peeks!
controller.setDelays(300, 500)
const point = (): void => {
const link = anchor()
const source = linkPeekSource(link.getAttribute('href')!)
if (!source)
throw new Error('符号链接不能预览')
controller.point({ anchor: link, source })
}
point()
await act(async () => vi.advanceTimersByTime(300))
expect(post).toHaveBeenCalledTimes(1)
expect(post).toHaveBeenLastCalledWith({ type: 'peekLink', id: 1, symbol: 'AsyncOpenAI' })
const peek: DefinitionPeek = {
path: 'client.py',
line: 1,
block: { id: '1', anchor: '1', kind: 'class', startLine: 1, endLine: 2, code: '', codeTokens: [], attributes: { kind: 'class' }, concepts: [], children: [], translation: [] },
names: [],
}
await act(async () => controller.answer(1, peek))
expect(container.querySelector('output')?.textContent).toBe('预览已显示')
point()
await act(async () => vi.advanceTimersByTime(1000))
expect(post).toHaveBeenCalledTimes(1)
expect(container.querySelector('output')?.textContent).toBe('预览已显示')
})
})
it('组件 TextPreview 随注释与定义预览模式切换更新相同正文的链接', async () => {
const preview = (kind: 'comment' | 'peek'): ReactElement => (
<MarkdownKindContext value={kind}>
<TextPreview text={{ markdown: '[Client]' }} />
</MarkdownKindContext>
)
await render(preview('comment'))
const original = anchor()
await render(preview('peek'))
expect(container.querySelector('a')).toBeNull()
expect(container.textContent).toBe('[Client]\n')
expect(original.isConnected).toBe(false)
await render(preview('comment'))
expect(anchor().getAttribute('href')).toBe('symbol:Client')
})
it('组件 DiffText 更新正文时保留 Markdown 排版和增删标记', async () => {
await render(<DiffText diff={{ markdown: [{ text: '[Client] ' }, { text: '旧说明', change: 'del' }, { text: '新说明', change: 'add' }] }} />)
expect(anchor().textContent).toBe('Client')
expect(container.querySelector('del')?.textContent).toBe('旧说明')
expect(container.querySelector('ins')?.textContent).toBe('新说明')
await render(<DiffText diff={{ markdown: [{ text: '[Client] ' }, { text: '**更新**', change: 'add' }] }} />)
expect(container.querySelector('del')).toBeNull()
expect(container.querySelector('ins strong')?.textContent).toBe('更新')
})
@@ -1,7 +1,7 @@
import type { ReactElement } from 'react'
import type { MarkdownKind } from './markdown'
import type { PreviewText } from './types'
import { createContext, useContext } from 'react'
import { createContext, useContext, useMemo } from 'react'
import { cn } from '@/lib/utils'
import { renderMarkdown } from './markdown'
import { SegmentText } from './SegmentText'
@@ -13,10 +13,15 @@ export const MarkdownKindContext = createContext<Exclude<MarkdownKind, 'answer'>
export function TextPreview({ text, className }: { text: PreviewText, className?: string }): ReactElement {
const kind = useContext(MarkdownKindContext)
if ('markdown' in text)
return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={{ __html: renderMarkdown(text.markdown, kind) }} />
return <MarkdownPreview html={renderMarkdown(text.markdown, kind)} className={className} />
return (
<p className={cn('cr-code', className)}>
<SegmentText segments={text} />
</p>
)
}
export function MarkdownPreview({ html, className }: { html: string, className?: string }): ReactElement {
const content = useMemo(() => ({ __html: html }), [html])
return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={content} />
}