diff --git a/vscode/package.json b/vscode/package.json index 9773783..bfe8b8e 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -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", diff --git a/vscode/pnpm-lock.yaml b/vscode/pnpm-lock.yaml index 7fd6fd5..0fa2a9e 100644 --- a/vscode/pnpm-lock.yaml +++ b/vscode/pnpm-lock.yaml @@ -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: diff --git a/vscode/webview-ui/src/codeReader/DiffText.tsx b/vscode/webview-ui/src/codeReader/DiffText.tsx index 59f9a7f..0cb1c90 100644 --- a/vscode/webview-ui/src/codeReader/DiffText.tsx +++ b/vscode/webview-ui/src/codeReader/DiffText.tsx @@ -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
+ return return (

diff --git a/vscode/webview-ui/src/codeReader/TextPreview.test.tsx b/vscode/webview-ui/src/codeReader/TextPreview.test.tsx new file mode 100644 index 0000000..aa5562c --- /dev/null +++ b/vscode/webview-ui/src/codeReader/TextPreview.test.tsx @@ -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 | 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 { + 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' + ? + : + } + + it('父级无关状态更新、传入等值新对象时,保留原链接且仍在页面中', async () => { + function Parent(): ReactElement { + const [count, setCount] = useState(0) + return ( + <> + + {preview('[AsyncOpenAI]')} + + ) + } + await render() + 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' + ? + : + await render(preview('[Client]')) + await render(empty()) + expect(container.querySelector('.cr-markdown')?.innerHTML).toBe('') + expect(container.querySelector('a')).toBeNull() + await render(component === 'TextPreview' + ? + : ) + 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>() + const [controller] = useState(() => new DefinitionPeeks(post, setShown)) + peeks = controller + return ( + <> + {preview('[AsyncOpenAI]')} + {shown ? '预览已显示' : '预览未显示'} + + ) + } + await render() + 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 => ( + + + + ) + 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() + expect(anchor().textContent).toBe('Client') + expect(container.querySelector('del')?.textContent).toBe('旧说明') + expect(container.querySelector('ins')?.textContent).toBe('新说明') + await render() + expect(container.querySelector('del')).toBeNull() + expect(container.querySelector('ins strong')?.textContent).toBe('更新') +}) diff --git a/vscode/webview-ui/src/codeReader/TextPreview.tsx b/vscode/webview-ui/src/codeReader/TextPreview.tsx index 9fda593..1aa624e 100644 --- a/vscode/webview-ui/src/codeReader/TextPreview.tsx +++ b/vscode/webview-ui/src/codeReader/TextPreview.tsx @@ -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 export function TextPreview({ text, className }: { text: PreviewText, className?: string }): ReactElement { const kind = useContext(MarkdownKindContext) if ('markdown' in text) - return

+ return return (

) } + +export function MarkdownPreview({ html, className }: { html: string, className?: string }): ReactElement { + const content = useMemo(() => ({ __html: html }), [html]) + return
+}