From 5120e50228f13f543d73173faba01f062a20a1ea Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 12 Oct 2026 16:56:31 +1100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E9=87=8C=E4=B8=8D=E5=B8=A6=E8=B7=AF=E5=BE=84=E7=9A=84?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E4=BB=8E=E8=A2=AB=E6=B3=A8=E9=87=8A=E7=9A=84?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E8=B5=B7=E4=B8=80=E5=B1=82=E5=B1=82=E5=BE=80?= =?UTF-8?q?=E5=A4=96=E6=89=BE=EF=BC=8C=E5=90=8C=E7=B1=BB=E7=9A=84=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E3=80=81=E7=B1=BB=E7=9A=84=E6=88=90=E5=91=98=E9=83=BD?= =?UTF-8?q?=E8=83=BD=E9=93=BE=E5=88=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/editor/messages.ts | 36 +++-- .../codeReader/editor/previewEditor.test.ts | 43 ++++++ vscode/src/codeReader/editor/previewEditor.ts | 58 ++++---- vscode/src/codeReader/symbolLinks.test.ts | 99 ++++++++++++-- vscode/src/codeReader/symbolLinks.ts | 49 +++++-- .../src/codeReader/BlockTree.test.tsx | 8 ++ .../webview-ui/src/codeReader/BlockTree.tsx | 6 +- .../src/codeReader/TextPreview.test.tsx | 3 +- .../src/codeReader/definitionPeek.test.ts | 25 +++- .../src/codeReader/definitionPeek.ts | 20 ++- .../src/codeReader/repoLink.test.ts | 126 +++++++++++++----- vscode/webview-ui/src/codeReader/repoLink.ts | 86 +++++++----- vscode/webview-ui/src/codeReader/types.ts | 36 +++-- .../src/codeReader/useDefinitionPeek.ts | 3 +- .../webview-ui/src/codeReader/useRepoLinks.ts | 19 +-- 15 files changed, 450 insertions(+), 167 deletions(-) diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 31f6fb2..63070b2 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -18,6 +18,16 @@ export type { Excluded, ShownName } export interface TranslatorOption { id: string, label: string } +/** + * 注释里一个不带路径的引用(`[Name]`、`[Class.member]`):symbol 是写的名字;block 是这段注释属于哪一块(块的 anchor), + * 从那块代码的声明起一层层往外找(见 symbolLinks.ts)。不在哪块的注释里的没有 block,只从文件顶层找。 + * 同一个名字写在不同块的注释里,指到的可以不一样。 + */ +export interface SymbolReference { + symbol: string + block?: string +} + /** * 让预览滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset), * 预览展开把它折叠住的块和属性行,再让就是这处定义的那一块或属性表里那一行闪几下。 @@ -140,9 +150,10 @@ export interface StickyScrollSettings { * diff:这份文件相比对比基准改了什么(审查工作副本里的、从「未提交的更改」点开的有改动的才有,见 [对比基准](vscode/src/codeReader/diff/diffBase.ts#DiffBase)):每发一份文档之后算好了补发, * 基准变了(提交后 HEAD 动了、换了审查的工单、文件有了改动、这个标签页换了从哪儿打开)再发;null 是不比了(退出了审查、改动提交了、从别处点开了) * openedFrom:这个标签页是从哪儿打开的,页面记进自己的状态,VS Code 重启后恢复这个标签页时随 ready 报回来;ready 后发一次,开着时又从侧栏点开、换了从哪儿打开的再发 - * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用 - * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到 - * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了 + * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用(原样带回问的那几条) + * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到, + * block 同 openRepoLink + * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了,block 同 openRepoLink * repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在 * callTreeCounts:version 这份文档里,每个能看调用树的块(BlockView.callTree)落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理 * callTree:anchor 那块的调用树。trees 空着、又没有 contractMiss 是没有调用它的地方;接口卡片在契约文件里没对上时带 contractMiss。 @@ -167,9 +178,9 @@ export type ExtensionToWebview | { type: 'rewriteEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string } | { type: 'diff', diff: DocumentDiff | null } | { type: 'openedFrom', from: OpenedFrom } - | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] } - | { type: 'repoLinkBroken', path?: string, symbol?: string } - | { type: 'repoLinkOpened', path?: string, symbol?: string } + | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: SymbolReference[] } + | { type: 'repoLinkBroken', path?: string, symbol?: string, block?: string } + | { type: 'repoLinkOpened', path?: string, symbol?: string, block?: string } | { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] } | { type: 'focusPathAsk' } | { type: 'callTreeCounts', version: number, counts: Record } @@ -183,7 +194,7 @@ export type ExtensionToWebview * version 是这份下标所属的文档版本 * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 * peekDefinition:鼠标停在带下标的名字上,要 offset 处名字的定义卡片(几处定义取第一处);id、version 同 probeDefinition - * peekLink:鼠标停在注释里指到符号的链接上(带 #符号 的仓库链接、不带路径的引用),要那个符号的定义卡片;path、symbol 同 openRepoLink + * peekLink:鼠标停在注释里指到符号的链接上(带 #符号 的仓库链接、不带路径的引用),要那个符号的定义卡片;path、symbol、block 同 openRepoLink * findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition * setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览 * setOnlyChanges:打开 / 关掉「只看改动」,扩展端记下后发 filter 给所有预览 @@ -200,8 +211,9 @@ export type ExtensionToWebview * occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发; * 扩展端按它设 when 子句用的上下文键 * openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录; - * 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),按这份文件找 symbol(本文件定义的,再是导入的,见 symbolLinks.ts) - * checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(symbols)问哪些找不到。id 标出是哪一次问 + * 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),按这份文件找 symbol:block 是它写在哪块的注释里(见 SymbolReference), + * 从那块的声明起一层层往外找本文件定义的,再是导入的(见 symbolLinks.ts) + * checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(references)问哪些找不到。id 标出是哪一次问 * checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问 * openCallTree:打开 anchor 那块的调用树。version 是这块所在的文档版本,对不上就不查 * goToCallNode:点了调用树上的一个节点,打开 uri 那个文件,跳到 line 行 character 列(都从 0 起,见 CallTreeNode.jump) @@ -220,15 +232,15 @@ export type WebviewToExtension | { type: 'probeDefinition', id: number, version: number, offset: number } | { type: 'goToDefinition', version: number, offset: number } | { type: 'peekDefinition', id: number, version: number, offset: number } - | { type: 'peekLink', id: number, path?: string, symbol: string } + | { type: 'peekLink', id: number, path?: string, symbol: string, block?: string } | { type: 'findOccurrences', id: number, version: number, offset: number } | { type: 'ask', askId: string, anchor: string, question: string, images: ClaudeImage[] } | { type: 'stopAsk', askId: string } | { type: 'deleteQa', anchor: string, entryId: string } | { type: 'selectProfile', kind: ChatKind, id: string } | { type: 'occurrenceNavigation', available: boolean } - | { type: 'openRepoLink', path?: string, symbol?: string } - | { type: 'checkRepoLinks', id: number, paths: string[], symbols: string[] } + | { type: 'openRepoLink', path?: string, symbol?: string, block?: string } + | { type: 'checkRepoLinks', id: number, paths: string[], references: SymbolReference[] } | { type: 'checkRepoImages', id: number, paths: string[] } | { type: 'openCallTree', version: number, anchor: string } | { type: 'goToCallNode', uri: string, line: number, character: number } diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index 95017fb..37aeba5 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -1,9 +1,13 @@ +import type { BlockView } from '../blockView' import type { DiffBase, OpenedFrom } from '../diff/diffBase' import type { ProfileChoice } from '../qa/askProfiles' import type { RewriteRequest } from '../qa/codeQa' +import type { SymbolNode } from '../symbolLinks' import type { ExtensionToWebview, WebviewToExtension } from './messages' import { describe, expect, it, vi } from 'vitest' import { window, workspace } from 'vscode' +import { documentMessage } from './documentMessage' +import { documentSymbolNodes } from './documentSymbols' import { PreviewEditorProvider } from './previewEditor' vi.mock('vscode', () => ({ @@ -40,6 +44,8 @@ vi.mock('./documentMessage', () => ({ imports: [], })), })) +/** 文档符号由各个测试给 */ +vi.mock('./documentSymbols', () => ({ documentSymbolNodes: vi.fn(async () => []) })) const SOURCE = ['// 旧说明', 'export function load() {}'] @@ -345,3 +351,40 @@ describe('问 AI 和重写注释的 profile 下拉各管各的', () => { expect(page.posted()).not.toContainEqual(expect.objectContaining({ type: 'profiles', kind: 'rewrite' })) }) }) + +describe('注释里不带路径的引用按它写在哪块的注释里找', () => { + const METHOD = 'class AgentService > function get_default_agent' + const HELPER = 'function helper' + + /** 一块有名字的代码:名字在源码里的下标是 at */ + function declared(anchor: string, at: number, children: BlockView[] = []): BlockView { + return { id: anchor, anchor, kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], defines: { startIndex: at, endIndex: at + 1 }, attributes: { kind: 'function' }, concepts: [], children, translation: [] } + } + function symbol(name: string, start: number, end: number, children: SymbolNode[] = []): SymbolNode { + return { name, range: { startIndex: start, endIndex: end }, selection: { startIndex: start, endIndex: start + name.length }, children } + } + + it('同一个名字:方法说明里的找得到同类的另一个方法,顶层函数说明里的找不到;标坏链接、点了打开都按各自那块找', async () => { + const blocks = [declared('class AgentService', 100, [declared(METHOD, 400)]), declared(HELPER, 950)] + vi.mocked(documentMessage).mockImplementationOnce(async input => ({ + message: { type: 'document', version: input.version, languageId: input.languageId, blocks, attributes: [], translators: [], translatorId: '', names: [] }, + displayNames: [], + symbols: [], + imports: [], + }) as never) + vi.mocked(documentSymbolNodes).mockResolvedValue([ + symbol('AgentService', 100, 900, [symbol('get_agent_admitted', 200, 300), symbol('get_default_agent', 400, 500)]), + symbol('helper', 950, 990), + ]) + const { provider, document } = opening() + const page = tab() + provider.resolveCustomTextEditor(document as never, page.panel) + await page.send({ type: 'ready' }) + + await page.send({ type: 'checkRepoLinks', id: 1, paths: [], references: [{ symbol: 'get_agent_admitted', block: METHOD }, { symbol: 'get_agent_admitted', block: HELPER }] }) + expect(page.posted()).toContainEqual({ type: 'repoLinksChecked', id: 1, missing: [], unresolved: [{ symbol: 'get_agent_admitted', block: HELPER }] }) + + await page.send({ type: 'openRepoLink', symbol: 'get_agent_admitted', block: HELPER }) + expect(page.posted()).toContainEqual({ type: 'repoLinkBroken', symbol: 'get_agent_admitted', block: HELPER }) + }) +}) diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 3f6d6a7..ea735b8 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -14,7 +14,7 @@ import type { TreeSitterRuntime } from '../runtime' import type { ImportedName, LinkTarget, SymbolNode } from '../symbolLinks' import type { BlockFilter } from './blockFilter' import type { FoundLocation } from './definitions' -import type { ChatKind, DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages' +import type { ChatKind, DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, SymbolReference, WebviewToExtension } from './messages' import type { TargetSpot } from './openTarget' import type { TopSubject } from './pathPreview' import { realpath } from 'node:fs/promises' @@ -33,7 +33,7 @@ import { DocumentNames, resolveNames } from '../nameResolution' import { PATH_ASK_ANCHOR } from '../qa/pathQa' import { askPrompt, followUpPrompt } from '../qa/prompt' import { DocumentLookups } from '../ruleLookups' -import { resolveReference } from '../symbolLinks' +import { referenceScope, resolveReference } from '../symbolLinks' import { definitionAction } from './definitionAction' import { definitionSiteNames, namesOnCard, peekBlock } from './definitionPeek' import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions' @@ -669,16 +669,16 @@ class Preview { await this.peekDefinition(msg.id, msg.version, msg.offset) return case 'peekLink': - await this.peekLink(msg.id, msg.path, msg.symbol) + await this.peekLink(msg.id, msg.path, msg.symbol, msg.block) return case 'findOccurrences': await this.findOccurrences(msg.id, msg.version, msg.offset) return case 'openRepoLink': - await this.openRepoLink(msg.path, msg.symbol) + await this.openRepoLink(msg.path, msg.symbol, msg.block) return case 'checkRepoLinks': - await this.checkRepoLinks(msg.id, msg.paths, msg.symbols) + await this.checkRepoLinks(msg.id, msg.paths, msg.references) return case 'checkRepoImages': await this.checkRepoImages(msg.id, msg.paths) @@ -770,10 +770,10 @@ class Preview { } /** 回答 webview 第 id 次要注释里的链接指的符号的卡片:符号同点这个链接时找的那个(symbolTarget),找不到没有。 */ - private async peekLink(id: number, rel: string | undefined, symbol: string): Promise { + private async peekLink(id: number, rel: string | undefined, symbol: string, block: string | undefined): Promise { let peek: DefinitionPeek | undefined try { - const target = await this.symbolTarget(rel, symbol) + const target = await this.symbolTarget(rel, symbol, block) peek = target && await this.peekAt(Uri.parse(target.uri), target.selection.startIndex) } catch (err) { @@ -843,8 +843,8 @@ class Preview { } } - /** 预览里这批仓库路径,哪些文件或目录不在;这批不带路径的引用,哪些找不到。 */ - private async checkRepoLinks(id: number, paths: string[], symbols: string[]): Promise { + /** 预览里这批仓库路径,哪些文件或目录不在;这批不带路径的引用,各按写在哪块找,哪些找不到。 */ + private async checkRepoLinks(id: number, paths: string[], references: SymbolReference[]): Promise { const root = await repoRootOf(this.document.uri.fsPath) const asked = new Map>() const symbolsOf = (uri: string): Promise => { @@ -858,10 +858,10 @@ class Preview { return rel return undefined })), - Promise.all(symbols.map(async symbol => await this.resolveSymbolLink(symbol, symbolsOf) ? undefined : symbol)), + Promise.all(references.map(async reference => await this.resolveSymbolLink(reference, symbolsOf) ? undefined : reference)), ]) if (this.ready) - this.post({ type: 'repoLinksChecked', id, missing: missing.filter(rel => rel !== undefined), unresolved: unresolved.filter(symbol => symbol !== undefined) }) + this.post({ type: 'repoLinksChecked', id, missing: missing.filter(rel => rel !== undefined), unresolved: unresolved.filter(reference => reference !== undefined) }) } /** 仓库根放进这个预览能读的本地目录,图片地址才能加载。真实位置和 git 给的路径不一样时两个都放。 */ @@ -905,19 +905,19 @@ class Preview { /** * 打开注释里的仓库链接:目录在代码阅读侧栏的树上定位,文件用编辑器打开。 - * 带了符号就按文档符号的名字跳过去;文件或符号没有就不打开。没有路径的是不带路径的引用,按这份文件找(见 resolveSymbolLink)。 + * 带了符号就按文档符号的名字跳过去;文件或符号没有就不打开。没有路径的是不带路径的引用,按这份文件、从它写在的那块(block)找(见 resolveSymbolLink)。 */ - private async openRepoLink(rel: string | undefined, symbol?: string): Promise { + private async openRepoLink(rel: string | undefined, symbol?: string, block?: string): Promise { const link = rel ?? `[${symbol}]` try { if (symbol !== undefined) { - const target = await this.symbolTarget(rel, symbol) + const target = await this.symbolTarget(rel, symbol, block) if (!target) { - this.markRepoLinkBroken(rel, symbol) + this.markRepoLinkBroken(rel, symbol, block) return } await this.showLinkTarget(target) - this.markRepoLinkOpened(rel, symbol) + this.markRepoLinkOpened(rel, symbol, block) return } if (rel === undefined) @@ -946,12 +946,12 @@ class Preview { } /** - * 链接指的符号在哪:没有 path 的是不带路径的引用,按这份文件找(resolveSymbolLink);有 path 的在那个文件的文档符号里找(locateRepoSymbol)。 - * 文件没有、是目录、符号没有时没有。点链接、悬停链接看定义卡片都按它找。 + * 链接指的符号在哪:没有 path 的是不带路径的引用,按这份文件、从它写在的那块(block)找(resolveSymbolLink); + * 有 path 的在那个文件的文档符号里找(locateRepoSymbol)。文件没有、是目录、符号没有时没有。点链接、悬停链接看定义卡片都按它找。 */ - private async symbolTarget(rel: string | undefined, symbol: string): Promise { + private async symbolTarget(rel: string | undefined, symbol: string, block: string | undefined): Promise { if (rel === undefined) - return this.resolveSymbolLink(symbol) + return this.resolveSymbolLink({ symbol, block }) const root = await repoRootOf(this.document.uri.fsPath) const entry = root === undefined ? undefined : await resolveRepoEntry(root, rel) return entry && !entry.isDirectory ? this.locateRepoSymbol(Uri.file(entry.fsPath), symbol) : undefined @@ -964,13 +964,15 @@ class Preview { } /** - * 注释里不带路径的引用(`[Name]`、`[Class.member]`)指到哪:本文件定义的优先,再是导入进来的(见 symbolLinks.ts)。 + * 注释里不带路径的引用(`[Name]`、`[Class.member]`)指到哪:从它写在的那块(block,在眼下这份块树里找)的声明起一层层往外找本文件定义的, + * 再是导入进来的(见 symbolLinks.ts)。检查坏链接、点击、悬停看定义卡片都走这里。 * 文档符号同仓库链接找符号那一套(symbols,一批引用一起问时共用一份),导入的名字问语言扩展它的定义(同跳到定义),定义几处取第一处。 */ - private async resolveSymbolLink(symbol: string, symbols: (uri: string) => Promise = documentSymbolNodes): Promise { - const { document } = this + private async resolveSymbolLink({ symbol, block }: SymbolReference, symbols: (uri: string) => Promise = documentSymbolNodes): Promise { + const { document, sent } = this + const at = block === undefined || !sent ? undefined : referenceScope(sent.blocks, block) try { - return await resolveReference(symbol, this.imports, { + return await resolveReference(symbol, at, this.imports, { uri: document.uri.toString(), symbols, definition: async (offset) => { @@ -998,14 +1000,14 @@ class Preview { await this.openElsewhere(doc.uri, { range: new Range(doc.positionAt(selection.startIndex), doc.positionAt(selection.endIndex)), offset: selection.startIndex }) } - private markRepoLinkBroken(path: string | undefined, symbol?: string): void { + private markRepoLinkBroken(path: string | undefined, symbol?: string, block?: string): void { if (this.ready) - this.post({ type: 'repoLinkBroken', ...(path !== undefined && { path }), ...(symbol !== undefined && { symbol }) }) + this.post({ type: 'repoLinkBroken', ...(path !== undefined && { path }), ...(symbol !== undefined && { symbol }), ...(block !== undefined && { block }) }) } - private markRepoLinkOpened(path: string | undefined, symbol?: string): void { + private markRepoLinkOpened(path: string | undefined, symbol?: string, block?: string): void { if (this.ready) - this.post({ type: 'repoLinkOpened', ...(path !== undefined && { path }), ...(symbol !== undefined && { symbol }) }) + this.post({ type: 'repoLinkOpened', ...(path !== undefined && { path }), ...(symbol !== undefined && { symbol }), ...(block !== undefined && { block }) }) } /** diff --git a/vscode/src/codeReader/symbolLinks.test.ts b/vscode/src/codeReader/symbolLinks.test.ts index 8d19c0f..ee3bc00 100644 --- a/vscode/src/codeReader/symbolLinks.test.ts +++ b/vscode/src/codeReader/symbolLinks.test.ts @@ -1,9 +1,10 @@ +import type { BlockView } from './blockView' import type { ReferenceSources, SymbolNode } from './symbolLinks' import { describe, expect, it } from 'vitest' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' import { importsIn } from './libraryRules' -import { importedNames, resolveReference, splitReference } from './symbolLinks' +import { importedNames, referenceScope, resolveReference, splitReference } from './symbolLinks' import { readFixture, WASM_DIR, withBlocks } from './testSupport' const py = createPythonAdapter(WASM_DIR) @@ -40,14 +41,14 @@ describe('不带路径的引用:指到哪', () => { it('本文件定义的优先于导入的;不问语言扩展', async () => { const found = sources({ 'file:///here.py': [symbol('Agent', 100, 200)] }, { 20: { uri: 'file:///models.py', at: 5, length: 5 } }) - expect(await resolveReference('Agent', imports, found)).toEqual({ uri: 'file:///here.py', selection: { startIndex: 100, endIndex: 105 } }) + expect(await resolveReference('Agent', undefined, imports, found)).toEqual({ uri: 'file:///here.py', selection: { startIndex: 100, endIndex: 105 } }) expect(found.asked).toEqual([]) }) it('本文件的 Class.member 在类底下找成员;类有、成员没有就是找不到,不再去导入里找', async () => { const here = sources({ 'file:///here.py': [symbol('Agent', 100, 200, [symbol('owner_id', 120, 140)])] }, { 20: { uri: 'file:///models.py', at: 5, length: 5 } }) - expect(await resolveReference('Agent.owner_id', imports, here)).toEqual({ uri: 'file:///here.py', selection: { startIndex: 120, endIndex: 128 } }) - expect(await resolveReference('Agent.missing', imports, here)).toBeUndefined() + expect(await resolveReference('Agent.owner_id', undefined, imports, here)).toEqual({ uri: 'file:///here.py', selection: { startIndex: 120, endIndex: 128 } }) + expect(await resolveReference('Agent.missing', undefined, imports, here)).toBeUndefined() expect(here.asked).toEqual([]) }) @@ -57,21 +58,101 @@ describe('不带路径的引用:指到哪', () => { 'file:///models.py': [symbol('Other', 0, 4, [symbol('owner_id', 1, 3)]), symbol('Agent', 10, 90, [symbol('owner_id', 30, 50)])], } const found = sources(files, { 20: { uri: 'file:///models.py', at: 10, length: 5 } }) - expect(await resolveReference('Agent', imports, found)).toEqual({ uri: 'file:///models.py', selection: { startIndex: 10, endIndex: 15 } }) - expect(await resolveReference('Agent.owner_id', imports, found)).toEqual({ uri: 'file:///models.py', selection: { startIndex: 30, endIndex: 38 } }) + expect(await resolveReference('Agent', undefined, imports, found)).toEqual({ uri: 'file:///models.py', selection: { startIndex: 10, endIndex: 15 } }) + expect(await resolveReference('Agent.owner_id', undefined, imports, found)).toEqual({ uri: 'file:///models.py', selection: { startIndex: 30, endIndex: 38 } }) expect(found.asked).toEqual([20, 20]) }) it('导入的是整个模块(定义落在文件开头)时成员从顶层找', async () => { const files = { 'file:///errors.py': [symbol('NotFound', 40, 80)] } const found = sources(files, { 7: { uri: 'file:///errors.py', at: 0, length: 0 } }) - expect(await resolveReference('errors.NotFound', [{ name: 'errors', offset: 7, range: { startIndex: 7, endIndex: 13 } }], found)) + expect(await resolveReference('errors.NotFound', undefined, [{ name: 'errors', offset: 7, range: { startIndex: 7, endIndex: 13 } }], found)) .toEqual({ uri: 'file:///errors.py', selection: { startIndex: 40, endIndex: 48 } }) }) it('本文件没定义、也没导入,或语言扩展答不上:找不到', async () => { - expect(await resolveReference('Nowhere', imports, sources({}))).toBeUndefined() - expect(await resolveReference('Agent', imports, sources({}))).toBeUndefined() + expect(await resolveReference('Nowhere', undefined, imports, sources({}))).toBeUndefined() + expect(await resolveReference('Agent', undefined, imports, sources({}))).toBeUndefined() + }) +}) + +describe('不带路径的引用:从被注释的声明起一层层往外找(KDoc)', () => { + const imports = [{ name: 'Agent', offset: 20, range: { startIndex: 20, endIndex: 25 } }] + /** services.py 的样子:AgentService 里套着类 Config 和几个方法,get_default_agent 里有个局部变量;顶层还有个同名的 helper。 */ + const here = (): ReturnType => sources({ + 'file:///here.py': [ + symbol('Agent', 20, 25), + symbol('AgentService', 100, 900, [ + symbol('Config', 120, 180, [symbol('timeout', 140, 150)]), + symbol('get_agent_admitted', 200, 300), + symbol('get_default_agent', 400, 500, [symbol('agent', 450, 460)]), + symbol('helper', 600, 700), + ]), + symbol('helper', 950, 990), + ], + }, { 20: { uri: 'file:///models.py', at: 10, length: 5 } }) + /** 各个声明的名字在源码里的下标:注释写在它的说明里时从这儿往外找。 */ + const SERVICE = 100 + const DEFAULT_AGENT = 400 + const HELPER = 950 + const at = (uri: string, start: number, name: string): unknown => ({ uri, selection: { startIndex: start, endIndex: start + name.length } }) + + it('方法说明里的 [同类的另一个方法] 找得到', async () => { + expect(await resolveReference('get_agent_admitted', DEFAULT_AGENT, imports, here())).toEqual(at('file:///here.py', 200, 'get_agent_admitted')) + }) + + it('类说明里的 [成员] 找得到;方法说明里的 [自己里面的名字] 也找得到', async () => { + expect(await resolveReference('get_default_agent', SERVICE, imports, here())).toEqual(at('file:///here.py', 400, 'get_default_agent')) + expect(await resolveReference('agent', DEFAULT_AGENT, imports, here())).toEqual(at('file:///here.py', 450, 'agent')) + }) + + it('近的先认:方法说明里的 [helper] 是同类的方法,顶层函数说明里的是顶层函数', async () => { + expect(await resolveReference('helper', DEFAULT_AGENT, imports, here())).toEqual(at('file:///here.py', 600, 'helper')) + expect(await resolveReference('helper', HELPER, imports, here())).toEqual(at('file:///here.py', 950, 'helper')) + }) + + it('顶层的、导入的照样找得到;导入语句里的名字不算本文件定义的', async () => { + const found = here() + expect(await resolveReference('AgentService', DEFAULT_AGENT, imports, found)).toEqual(at('file:///here.py', 100, 'AgentService')) + expect(await resolveReference('Agent', DEFAULT_AGENT, imports, found)).toEqual(at('file:///models.py', 10, 'Agent')) + expect(found.asked).toEqual([20]) + }) + + it('同一个名字写在两块的说明里:方法说明里找得到同类的方法,顶层函数说明里、不在哪个声明的注释里都找不到', async () => { + expect(await resolveReference('get_agent_admitted', DEFAULT_AGENT, imports, here())).toEqual(at('file:///here.py', 200, 'get_agent_admitted')) + expect(await resolveReference('get_agent_admitted', HELPER, imports, here())).toBeUndefined() + expect(await resolveReference('get_agent_admitted', undefined, imports, here())).toBeUndefined() + }) + + it('写成 Class.member 的:第一段同样一层层找,再往下找成员', async () => { + expect(await resolveReference('Config.timeout', DEFAULT_AGENT, imports, here())).toEqual(at('file:///here.py', 140, 'timeout')) + expect(await resolveReference('AgentService.get_agent_admitted', HELPER, imports, here())).toEqual(at('file:///here.py', 200, 'get_agent_admitted')) + expect(await resolveReference('Config.timeout', HELPER, imports, here())).toBeUndefined() + }) +}) + +describe('注释写在哪块里:从哪个声明起往外找', () => { + function block(anchor: string, defines: number | undefined, children: BlockView[] = []): BlockView { + return { anchor, children, ...(defines !== undefined && { defines: { startIndex: defines, endIndex: defines + 1 } }) } as BlockView + } + const blocks = [ + block('class AgentService', 100, [ + block('class AgentService > function get_default_agent', 400, [ + block('class AgentService > function get_default_agent > if @3fa1c2d0', undefined), + ]), + ]), + block('comment @0b1c2d3e', undefined), + ] + + it('有名字的块(声明)是它自己的名字;没名字的(注释、语句)取外层最近的有名字的块', () => { + expect(referenceScope(blocks, 'class AgentService')).toBe(100) + expect(referenceScope(blocks, 'class AgentService > function get_default_agent')).toBe(400) + expect(referenceScope(blocks, 'class AgentService > function get_default_agent > if @3fa1c2d0')).toBe(400) + }) + + it('顶层没名字的块、块树里没有的块:没有,只从文件顶层找', () => { + expect(referenceScope(blocks, 'comment @0b1c2d3e')).toBeUndefined() + expect(referenceScope(blocks, 'class Gone')).toBeUndefined() }) }) diff --git a/vscode/src/codeReader/symbolLinks.ts b/vscode/src/codeReader/symbolLinks.ts index 2d75757..bcfa8eb 100644 --- a/vscode/src/codeReader/symbolLinks.ts +++ b/vscode/src/codeReader/symbolLinks.ts @@ -1,8 +1,11 @@ +import type { BlockView } from './blockView' import type { ImportFacts, ImportItem, Node, SourceRange } from './contract' +import { findBlock } from './qa/prompt' /** - * 注释里不带路径的引用(KDoc 的 `[Name]`、`[Class.member]`)指到哪:先找本文件定义的,再找本文件导入进来的,都没有就找不到。 - * 符号、定义问语言扩展,交给调用方(预览)去问;这里只管按什么顺序找、怎么拆名字(不碰 VS Code)。 + * 注释里不带路径的引用(KDoc 的 `[Name]`、`[Class.member]`)指到哪:从这段注释说明的那个声明起,一层层往外找本文件定义的, + * 再找本文件导入进来的,都没有就找不到。 + * 符号、定义问语言扩展,交给调用方(预览)去问;这里只管从哪儿起、按什么顺序找、怎么拆名字(不碰 VS Code)。 */ /** 本文件某条导入引进来的一个名字:名字、问定义时问的位置(导入语句里那个名字的下标)、这一项在源码里的范围。 */ @@ -86,23 +89,35 @@ function deepFind(symbols: readonly SymbolNode[], name: string | undefined): Sym return undefined } -/** 盖住 offset 的最里层的符号;定义落在文件开头(导入的是整个模块)时没有。 */ -function innermost(symbols: readonly SymbolNode[], offset: number): SymbolNode | undefined { +/** 盖住 offset 的各层符号,从最外层到最里层;offset 不在哪个符号里(比如落在文件开头)时是空的。 */ +function enclosing(symbols: readonly SymbolNode[], offset: number): SymbolNode[] { const outer = symbols.find(symbol => covers(symbol.range, offset)) - return outer && (innermost(outer.children, offset) ?? outer) + return outer ? [outer, ...enclosing(outer.children, offset)] : [] +} + +/** 本文件里叫 name 的定义:从 at 那个声明起由近到远一层层看(见 resolveReference 第 1 步),导入语句里的名字不算。 */ +function definedNear(symbols: readonly SymbolNode[], at: number | undefined, name: string, imports: readonly ImportedName[]): SymbolNode | undefined { + const scopes = at === undefined ? [] : enclosing(symbols, at).reverse() + for (const layer of [...scopes.map(scope => scope.children), symbols]) { + const found = layer.find(symbol => symbol.name === name && !imports.some(item => covers(item.range, symbol.selection.startIndex))) + if (found) + return found + } + return undefined } /** - * 引用指到哪,按 KDoc 的规矩: - * 1. 本文件定义的:顶层的符号(类、函数、常量、变量)名字对上,`Class.member` 再在它底下找成员;导入语句里的名字不算本文件定义的。 + * 引用指到哪,按 KDoc 的规矩(不带限定的名字,当它写在这段注释说明的那个声明里面来认): + * 1. 本文件定义的,由近到远一层层找:at 是注释说明的那个声明的名字在源码里的下标(见 referenceScope)。先看盖住 at 的最里层符号 + * (就是这个声明)的直接子符号,再一层层往外看包着它的符号的直接子符号,最后看文件顶层;哪层先有这个名字(`Class.member` 看第一段) + * 就是哪个,`Class.member` 再在它底下找成员。导入语句里的名字不算本文件定义的。没有 at(不在哪个声明的注释里)只看文件顶层。 * 2. 本文件导入进来的:名字(`Class.member` 的第一段)是某条导入引进来的,问导入语句里那个名字的定义;`Class.member` 再在定义所在文件的 * 符号里找:定义落在哪个符号里就在它底下找,落在文件开头(导入的是模块)就从顶层找。 - * 3. 都没有:找不到。本文件定义了这个名字、却没有这个成员时也是找不到,不再去导入里找。 + * 3. 都没有:找不到。本文件某层有这个名字、却没有这个成员时也是找不到,不再去更外层、导入里找。 */ -export async function resolveReference(reference: string, imports: readonly ImportedName[], sources: ReferenceSources): Promise { +export async function resolveReference(reference: string, at: number | undefined, imports: readonly ImportedName[], sources: ReferenceSources): Promise { const { head, members } = splitReference(reference) - const local = await sources.symbols(sources.uri) - const own = local.find(symbol => symbol.name === head && !imports.some(item => covers(item.range, symbol.selection.startIndex))) + const own = definedNear(await sources.symbols(sources.uri), at, head, imports) if (own) { const found = members.length > 0 ? memberOf(own.children, members) : own return found && { uri: sources.uri, selection: found.selection } @@ -112,7 +127,17 @@ export async function resolveReference(reference: string, imports: readonly Impo if (!target || members.length === 0) return target const symbols = await sources.symbols(target.uri) - const holder = innermost(symbols, target.selection.startIndex) + const holder = enclosing(symbols, target.selection.startIndex).at(-1) const found = memberOf(holder ? holder.children : symbols, members) return found && { uri: target.uri, selection: found.selection } } + +/** + * 注释写在 anchor 那块里时,引用从哪个声明起找(resolveReference 的 at):这块有名字(是个声明)就是它的名字在源码里的下标; + * 没名字的(注释、语句……)取外层最近的有名字的块。外层也都没名字、块树里没有这块时没有,只从文件顶层找。 + */ +export function referenceScope(blocks: BlockView[], anchor: string): number | undefined { + const found = findBlock(blocks, anchor) + const declared = found && [...found.outer, found.block].reverse().find(block => block.defines !== undefined) + return declared?.defines?.startIndex +} diff --git a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx index 4075ada..1cefd12 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx @@ -177,6 +177,14 @@ describe('挂在卡片子行上的子块', () => { }) }) +describe('卡片记着块记号', () => { + it('每张卡片带着它那块的记号(data-anchor):说明里不带路径的引用靠它认自己写在哪块', () => { + const method = view('0.1', 'function', '', { anchor: 'class AgentService > function get_default_agent', translation: { markdown: '先看 [get_agent_admitted]' } }) + const markup = renderToStaticMarkup() + expect(markup).toMatch(/
]*data-anchor="class AgentService > function get_default_agent"[\s\S]*href="symbol:get_agent_admitted"/) + }) +}) + describe('「注释」一行和「重写注释」按钮', () => { const comment = { label: '注释', value: { markdown: '按名字找' } } const html = (block: BlockView): string => renderToStaticMarkup() diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 2493f18..8b6a842 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -312,7 +312,10 @@ function cardSlots(block: BlockView, tree: BlockTreeState): GridSlots | undefine } } -/** 一块:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools,只读的块树没有)。 */ +/** + * 一块:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools,只读的块树没有)。 + * 卡片上记着块记号(data-anchor),说明里不带路径的引用靠它认自己写在哪块(见 commentLinkOf)。 + */ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement { const { edit } = tree const editing = edit?.editing.has(block.id) ?? false @@ -327,6 +330,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): className="cr-block relative flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2" {...kindProps(block)} data-id={block.id} + data-anchor={block.anchor} data-line={block.startLine} data-editing={editing ? '' : undefined} data-change={change?.change} diff --git a/vscode/webview-ui/src/codeReader/TextPreview.test.tsx b/vscode/webview-ui/src/codeReader/TextPreview.test.tsx index aa5562c..f4ac6df 100644 --- a/vscode/webview-ui/src/codeReader/TextPreview.test.tsx +++ b/vscode/webview-ui/src/codeReader/TextPreview.test.tsx @@ -9,6 +9,7 @@ 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 { commentLinkOf } from './repoLink' import { MarkdownKindContext, TextPreview } from './TextPreview' let container: HTMLDivElement @@ -121,7 +122,7 @@ describe.each(['TextPreview', 'DiffText'] as const)('%s 的 Markdown 页面元 controller.setDelays(300, 500) const point = (): void => { const link = anchor() - const source = linkPeekSource(link.getAttribute('href')!) + const source = linkPeekSource(commentLinkOf(link)) if (!source) throw new Error('符号链接不能预览') controller.point({ anchor: link, source }) diff --git a/vscode/webview-ui/src/codeReader/definitionPeek.test.ts b/vscode/webview-ui/src/codeReader/definitionPeek.test.ts index ea43c21..e0b1d58 100644 --- a/vscode/webview-ui/src/codeReader/definitionPeek.test.ts +++ b/vscode/webview-ui/src/codeReader/definitionPeek.test.ts @@ -2,6 +2,7 @@ import type { PeekTarget, ShownPeek } from './definitionPeek' import type { DefinitionPeek, WebviewToExtension } from './types' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DefinitionPeeks, linkPeekSource, placePeek } from './definitionPeek' +import { parseCommentLink } from './repoLink' const DELAY = 300 const HIDING = 500 @@ -99,6 +100,17 @@ describe('定义卡片什么时候弹', () => { expect(shown()).toBeUndefined() }) + it('不带路径的引用:问 peekLink 和点标题时都带着它写在哪块', () => { + const { peeks, sent } = setup() + const anchor = { name: 'reference' } + peeks.point({ anchor, source: { symbol: 'get_agent_admitted', block: 'class AgentService > function get_default_agent' } }) + vi.advanceTimersByTime(DELAY) + expect(sent).toEqual([{ type: 'peekLink', id: 1, symbol: 'get_agent_admitted', block: 'class AgentService > function get_default_agent' }]) + peeks.answer(1, PEEK) + peeks.jump() + expect(sent.at(-1)).toEqual({ type: 'openRepoLink', symbol: 'get_agent_admitted', block: 'class AgentService > function get_default_agent' }) + }) + it('还没收到 VS Code 的悬停延迟:不弹', () => { const sent: WebviewToExtension[] = [] const peeks = new DefinitionPeeks(msg => sent.push(msg), () => {}) @@ -208,15 +220,16 @@ describe('定义卡片什么时候收', () => { }) describe('链接能不能弹定义卡片', () => { - it('指到符号的才能:带 #符号 的仓库链接、不带路径的引用', () => { - expect(linkPeekSource('app/devices.py#Repo.find')).toEqual({ path: 'app/devices.py', symbol: 'Repo.find' }) - expect(linkPeekSource('symbol:DesktopOnly')).toEqual({ symbol: 'DesktopOnly' }) + it('指到符号的才能:带 #符号 的仓库链接、不带路径的引用(带着写在哪块)', () => { + expect(linkPeekSource(parseCommentLink('app/devices.py#Repo.find'))).toEqual({ path: 'app/devices.py', symbol: 'Repo.find' }) + expect(linkPeekSource(parseCommentLink('symbol:DesktopOnly'))).toEqual({ symbol: 'DesktopOnly' }) + expect(linkPeekSource({ symbol: 'DesktopOnly', block: 'function gate' })).toEqual({ symbol: 'DesktopOnly', block: 'function gate' }) }) it('只指到文件、目录的,网址:不能', () => { - expect(linkPeekSource('app/devices.py')).toBeUndefined() - expect(linkPeekSource('app/')).toBeUndefined() - expect(linkPeekSource('https://example.com#x')).toBeUndefined() + expect(linkPeekSource(parseCommentLink('app/devices.py'))).toBeUndefined() + expect(linkPeekSource(parseCommentLink('app/'))).toBeUndefined() + expect(linkPeekSource(parseCommentLink('https://example.com#x'))).toBeUndefined() }) }) diff --git a/vscode/webview-ui/src/codeReader/definitionPeek.ts b/vscode/webview-ui/src/codeReader/definitionPeek.ts index 760c8a9..97fac03 100644 --- a/vscode/webview-ui/src/codeReader/definitionPeek.ts +++ b/vscode/webview-ui/src/codeReader/definitionPeek.ts @@ -1,11 +1,11 @@ +import type { CommentLink } from './repoLink' import type { DefinitionPeek, WebviewToExtension } from './types' -import { parseCommentLink } from './repoLink' /** 定义卡片浮窗的根上的属性:浮窗里的名字、链接是别的文件的,页面上管名字的那几处(跳到定义、高亮各处出现、再弹卡片)都不认。 */ export const PEEK_ATTRIBUTE = 'data-peek' -/** 能弹定义卡片的东西:带源码下标的名字,或注释里指到符号的链接(带 #符号 的仓库链接、不带路径的引用)。 */ -export type PeekSource = { offset: number } | { path?: string, symbol: string } +/** 能弹定义卡片的东西:带源码下标的名字,或注释里指到符号的链接(带 #符号 的仓库链接、不带路径的引用,后者带着写在哪块)。 */ +export type PeekSource = { offset: number } | (CommentLink & { symbol: string }) /** 指针下能弹定义卡片的一处:页面上的哪个元素(浮窗挂在它旁边),它是什么。 */ export interface PeekTarget { @@ -31,12 +31,9 @@ export function documentNameAt(target: EventTarget | null): HTMLElement | null { return name && !inPeek(name) ? name : null } -/** 注释里一个链接地址能不能弹定义卡片:指到符号的才能(带 #符号 的仓库链接、不带路径的引用),只指到文件、目录的不能。 */ -export function linkPeekSource(href: string): PeekSource | undefined { - const link = parseCommentLink(href) - if (link?.symbol === undefined) - return undefined - return link.path === undefined ? { symbol: link.symbol } : { path: link.path, symbol: link.symbol } +/** 注释里一个链接(见 commentLinkOf)能不能弹定义卡片:指到符号的才能(带 #符号 的仓库链接、不带路径的引用),只指到文件、目录的不能。 */ +export function linkPeekSource(link: CommentLink | undefined): PeekSource | undefined { + return link?.symbol === undefined ? undefined : { ...link, symbol: link.symbol } } /** 浮窗与名字之间的间距。 */ @@ -186,9 +183,8 @@ export class DefinitionPeeks { this.post({ type: 'peekDefinition', id, version, offset: source.offset }) return } - const link = source.path === undefined ? { symbol: source.symbol } : { path: source.path, symbol: source.symbol } - this.asked = { id, anchor, jump: { type: 'openRepoLink', ...link } } - this.post({ type: 'peekLink', id, ...link }) + this.asked = { id, anchor, jump: { type: 'openRepoLink', ...source } } + this.post({ type: 'peekLink', id, ...source }) } private cancelAsk(): void { diff --git a/vscode/webview-ui/src/codeReader/repoLink.test.ts b/vscode/webview-ui/src/codeReader/repoLink.test.ts index 8ccf4cd..2eab08d 100644 --- a/vscode/webview-ui/src/codeReader/repoLink.test.ts +++ b/vscode/webview-ui/src/codeReader/repoLink.test.ts @@ -1,6 +1,17 @@ +// @vitest-environment happy-dom + +import type { CommentLink } from './repoLink' import type { WebviewToExtension } from './types' import { describe, expect, it } from 'vitest' -import { parseCommentLink, parseRepoLink, RepoLinks, symbolHref } from './repoLink' +import { commentLinkOf, parseCommentLink, parseRepoLink, RepoLinks, symbolHref } from './repoLink' + +/** 页面上一个链接地址是哪种能点的链接;测试里写的都得是 */ +function link(href: string): CommentLink { + const parsed = parseCommentLink(href) + if (!parsed) + throw new Error(`不是能点的链接:${href}`) + return parsed +} describe('parseRepoLink', () => { it('合法路径', () => { @@ -55,36 +66,36 @@ function setup(): { links: RepoLinks, sent: WebviewToExtension[] } { describe('repoLinks', () => { it('点仓库链接发给扩展端,http 不管', () => { const { links, sent } = setup() - expect(links.click('backend/src/luffy_agent/agent/models.py#Agent.owner_id')).toBe(true) + expect(links.click(parseCommentLink('backend/src/luffy_agent/agent/models.py#Agent.owner_id'))).toBe(true) expect(sent).toEqual([{ type: 'openRepoLink', path: 'backend/src/luffy_agent/agent/models.py', symbol: 'Agent.owner_id', }]) - expect(links.click('https://example.com')).toBe(false) - expect(links.click('file.py#123')).toBe(false) + expect(links.click(parseCommentLink('https://example.com'))).toBe(false) + expect(links.click(parseCommentLink('file.py#123'))).toBe(false) }) it('路径变了才问哪些文件没有,迟到的回答丢掉', () => { const { links, sent } = setup() - links.scan(['vendor/agents', 'https://example.com', 'backend/a.py#Agent']) - links.scan(['backend/a.py#Agent', 'vendor/agents']) - expect(sent).toEqual([{ type: 'checkRepoLinks', id: 1, paths: ['backend/a.py', 'vendor/agents'], symbols: [] }]) + links.scan([link('vendor/agents'), link('backend/a.py#Agent')]) + links.scan([link('backend/a.py#Agent'), link('vendor/agents')]) + expect(sent).toEqual([{ type: 'checkRepoLinks', id: 1, paths: ['backend/a.py', 'vendor/agents'], references: [] }]) expect(links.checked(0, ['vendor/agents'], [])).toBe(false) - expect(links.isBroken('vendor/agents')).toBe(false) + expect(links.isBroken(link('vendor/agents'))).toBe(false) expect(links.checked(1, ['vendor/agents'], [])).toBe(true) - expect(links.isBroken('vendor/agents')).toBe(true) - expect(links.isBroken('backend/a.py#Agent')).toBe(false) + expect(links.isBroken(link('vendor/agents'))).toBe(true) + expect(links.isBroken(link('backend/a.py#Agent'))).toBe(false) }) it('符号没有才标坏;打开成功就不再标', () => { const { links } = setup() - links.broken('backend/a.py', 'Agent') - expect(links.isBroken('backend/a.py#Agent')).toBe(true) - expect(links.isBroken('backend/a.py')).toBe(false) - links.opened('backend/a.py', 'Agent') - expect(links.isBroken('backend/a.py#Agent')).toBe(false) + links.broken({ path: 'backend/a.py', symbol: 'Agent' }) + expect(links.isBroken(link('backend/a.py#Agent'))).toBe(true) + expect(links.isBroken(link('backend/a.py'))).toBe(false) + links.opened({ path: 'backend/a.py', symbol: 'Agent' }) + expect(links.isBroken(link('backend/a.py#Agent'))).toBe(false) }) }) @@ -98,32 +109,83 @@ describe('不带路径的引用', () => { expect(parseCommentLink('backend/a.py#Agent')).toEqual({ path: 'backend/a.py', symbol: 'Agent' }) }) - it('点了发 openRepoLink(没有 path);和仓库路径一起问,找不到的标坏,打开成功就不再标', () => { + it('点了发 openRepoLink(没有 path,带着写在哪块);和仓库路径一起问,找不到的标坏,打开成功就不再标', () => { const { links, sent } = setup() - expect(links.click('symbol:LocalRuntimeDisabled')).toBe(true) - expect(sent).toEqual([{ type: 'openRepoLink', symbol: 'LocalRuntimeDisabled' }]) + expect(links.click({ symbol: 'LocalRuntimeDisabled', block: 'function gate' })).toBe(true) + expect(sent).toEqual([{ type: 'openRepoLink', symbol: 'LocalRuntimeDisabled', block: 'function gate' }]) - links.scan(['symbol:USER', 'backend/a.py', 'symbol:Missing', 'symbol:USER']) - expect(sent.at(-1)).toEqual({ type: 'checkRepoLinks', id: 1, paths: ['backend/a.py'], symbols: ['Missing', 'USER'] }) - expect(links.checked(1, [], ['Missing'])).toBe(true) - expect(links.isBroken('symbol:Missing')).toBe(true) - expect(links.isBroken('symbol:USER')).toBe(false) - links.opened(undefined, 'Missing') - expect(links.isBroken('symbol:Missing')).toBe(false) - links.broken(undefined, 'USER') - expect(links.isBroken('symbol:USER')).toBe(true) + links.scan([{ symbol: 'USER', block: 'a' }, link('backend/a.py'), { symbol: 'Missing', block: 'a' }, { symbol: 'USER', block: 'a' }]) + expect(sent.at(-1)).toEqual({ type: 'checkRepoLinks', id: 1, paths: ['backend/a.py'], references: [{ symbol: 'Missing', block: 'a' }, { symbol: 'USER', block: 'a' }] }) + expect(links.checked(1, [], [{ symbol: 'Missing', block: 'a' }])).toBe(true) + expect(links.isBroken({ symbol: 'Missing', block: 'a' })).toBe(true) + expect(links.isBroken({ symbol: 'USER', block: 'a' })).toBe(false) + links.opened({ symbol: 'Missing', block: 'a' }) + expect(links.isBroken({ symbol: 'Missing', block: 'a' })).toBe(false) + links.broken({ symbol: 'USER', block: 'a' }) + expect(links.isBroken({ symbol: 'USER', block: 'a' })).toBe(true) + }) + + it('同一个名字写在两块的注释里:分开问,一块找得到一块找不到时各标各的', () => { + const { links, sent } = setup() + const inMethod = { symbol: 'get_agent_admitted', block: 'class AgentService > function get_default_agent' } + const inFunction = { symbol: 'get_agent_admitted', block: 'function helper' } + const outside = { symbol: 'get_agent_admitted' } + links.scan([inMethod, inFunction, outside, inMethod]) + expect(sent).toEqual([{ type: 'checkRepoLinks', id: 1, paths: [], references: [outside, inMethod, inFunction] }]) + + links.checked(1, [], [inFunction, outside]) + expect(links.isBroken(inMethod)).toBe(false) + expect(links.isBroken(inFunction)).toBe(true) + expect(links.isBroken(outside)).toBe(true) + + links.opened(inFunction) + links.broken(inMethod) + expect(links.isBroken(inFunction)).toBe(false) + expect(links.isBroken(inMethod)).toBe(true) + expect(links.isBroken(outside)).toBe(true) }) it('同样的链接不再问;文档换了版本(stale)再问一次', () => { const { links, sent } = setup() - links.scan(['symbol:USER']) - links.scan(['symbol:USER']) + links.scan([{ symbol: 'USER', block: 'a' }]) + links.scan([{ symbol: 'USER', block: 'a' }]) expect(sent).toHaveLength(1) links.stale() - links.scan(['symbol:USER']) + links.scan([{ symbol: 'USER', block: 'a' }]) expect(sent).toEqual([ - { type: 'checkRepoLinks', id: 1, paths: [], symbols: ['USER'] }, - { type: 'checkRepoLinks', id: 2, paths: [], symbols: ['USER'] }, + { type: 'checkRepoLinks', id: 1, paths: [], references: [{ symbol: 'USER', block: 'a' }] }, + { type: 'checkRepoLinks', id: 2, paths: [], references: [{ symbol: 'USER', block: 'a' }] }, ]) }) + + it('名字一样、写在另一块里的也算变了,要再问', () => { + const { links, sent } = setup() + links.scan([{ symbol: 'USER', block: 'a' }]) + links.scan([{ symbol: 'USER', block: 'b' }]) + expect(sent.map(msg => msg.type === 'checkRepoLinks' && msg.references)).toEqual([[{ symbol: 'USER', block: 'a' }], [{ symbol: 'USER', block: 'b' }]]) + }) +}) + +describe('页面上的链接写在哪块的注释里', () => { + it('不带路径的引用带上离它最近的块卡片的块记号;仓库路径链接、不在块卡片里的、删掉的块(没有块记号)里的不带', () => { + document.body.innerHTML = ` + + + + + ` + const at = (id: string): CommentLink | undefined => commentLinkOf(document.getElementById(id)!) + expect(at('method')).toEqual({ symbol: 'get_agent_admitted', block: 'class AgentService > function get_default_agent' }) + expect(at('slotted')).toEqual({ symbol: 'get_agent_admitted', block: 'class AgentService > function get_default_agent > if @3fa1c2d0' }) + expect(at('function')).toEqual({ symbol: 'get_agent_admitted', block: 'function helper' }) + expect(at('path')).toEqual({ path: 'backend/a.py', symbol: 'Agent' }) + expect(at('removed')).toEqual({ symbol: 'get_agent_admitted' }) + expect(at('outside')).toEqual({ symbol: 'get_agent_admitted' }) + expect(at('web')).toBeUndefined() + }) }) diff --git a/vscode/webview-ui/src/codeReader/repoLink.ts b/vscode/webview-ui/src/codeReader/repoLink.ts index 94df8bb..0d1c6ef 100644 --- a/vscode/webview-ui/src/codeReader/repoLink.ts +++ b/vscode/webview-ui/src/codeReader/repoLink.ts @@ -1,4 +1,4 @@ -import type { WebviewToExtension } from './types' +import type { SymbolReference, WebviewToExtension } from './types' /** 注释里指向仓库代码的链接:从仓库根起的路径,可选一个符号。 */ export interface RepoLink { @@ -9,12 +9,11 @@ export interface RepoLink { } /** - * 注释里不带路径的引用(`[Name]`、`[Class.member]`):没有路径,扩展端按当前文件找这个符号(本文件定义的,再是导入的)。 + * 注释里不带路径的引用(`[Name]`、`[Class.member]`):没有路径,扩展端按当前文件、从它写在的那块(block,见 commentLinkOf)找这个符号。 * 页面上它是地址为 `symbol:Name` 的链接(见 symbolHref)。 */ -export interface SymbolLink { +export interface SymbolLink extends SymbolReference { path?: undefined - symbol: string } /** 注释里能点的链接:仓库路径链接,或不带路径的引用。 */ @@ -85,13 +84,37 @@ function isSymbol(symbol: string): boolean { return (parts.length === 1 || parts.length === 2) && parts.every(part => IDENT.test(part)) } +/** + * 页面上一个链接元素是哪种能点的链接(同 parseCommentLink)。不带路径的引用再带上它写在哪块的注释里:离它最近的那张块卡片(.cr-block) + * 上记着的块记号(data-anchor);不在块卡片里的、那张卡片没记块记号的(删掉的块)不带,扩展端只从文件顶层找。 + */ +export function commentLinkOf(element: Element): CommentLink | undefined { + const link = parseCommentLink(element.getAttribute('href') ?? '') + if (!link || link.path !== undefined) + return link + const block = element.closest('.cr-block')?.dataset.anchor + return block === undefined ? link : { ...link, block } +} + function symbolKey(path: string, symbol: string): string { return `${path}#${symbol}` } +/** 扩展端报回来打没打开的一个链接(repoLinkBroken / repoLinkOpened),path、symbol、block 同 openRepoLink。 */ +interface ReportedLink { + path?: string + symbol?: string + block?: string +} + +/** 不带路径的引用的键:同一个名字写在不同块的注释里分开记(指到的可以不一样)。 */ +function referenceKey({ symbol, block }: SymbolReference): string { + return `${symbol}\u0000${block ?? ''}` +} + /** * 页面上的仓库链接和不带路径的引用:点了发给扩展端打开;预览出来后把路径、引用发去问哪些文件没有、哪些引用找不到; - * 扩展端说没有的,标成坏的。仓库链接的符号在不在要点了才知道。 + * 扩展端说没有的,标成坏的。仓库链接的符号在不在要点了才知道。不带路径的引用按(写在哪块,名字)分开问、分开标。 */ export class RepoLinks { private request = 0 @@ -99,36 +122,36 @@ export class RepoLinks { private sent = '' private missingPaths = new Set() private missingSymbols = new Set() - /** 找不到的不带路径的引用 */ + /** 找不到的不带路径的引用(referenceKey) */ private unresolved = new Set() constructor(private readonly post: (msg: WebviewToExtension) => void) {} - /** 点在仓库链接或不带路径的引用上:发给扩展端打开,返回 true。别的链接不管。 */ - click(href: string | null): boolean { - const link = href === null ? undefined : parseCommentLink(href) + /** 点在仓库链接或不带路径的引用上(见 commentLinkOf):发给扩展端打开,返回 true。别的链接(没认出来的)不管。 */ + click(link: CommentLink | undefined): boolean { if (!link) return false - this.post({ type: 'openRepoLink', ...(link.path !== undefined && { path: link.path }), ...(link.symbol !== undefined && { symbol: link.symbol }) }) + this.post({ type: 'openRepoLink', ...link }) return true } - /** 页面上现有的链接。路径、引用变了(或文档换了版本,见 stale)才问扩展端哪些路径没有、哪些引用找不到。 */ - scan(hrefs: readonly string[]): void { - const links = hrefs.flatMap((href) => { - const link = parseCommentLink(href) - return link ? [link] : [] - }) + /** 页面上现有的链接。路径、引用(名字或写在哪块)变了(或文档换了版本,见 stale)才问扩展端哪些路径没有、哪些引用找不到。 */ + scan(links: readonly CommentLink[]): void { const paths = [...new Set(links.flatMap(link => link.path === undefined ? [] : [link.path]))].sort() - const symbols = [...new Set(links.flatMap(link => link.path === undefined ? [link.symbol] : []))].sort() - const key = `${paths.join('\n')}\u0000${symbols.join('\n')}` + const references = new Map() + for (const link of links) { + if (link.path === undefined) + references.set(referenceKey(link), link) + } + const keys = [...references.keys()].sort() + const key = JSON.stringify([paths, keys]) if (key === this.sent) return this.sent = key - if (paths.length === 0 && symbols.length === 0) + if (paths.length === 0 && keys.length === 0) return this.request += 1 - this.post({ type: 'checkRepoLinks', id: this.request, paths, symbols }) + this.post({ type: 'checkRepoLinks', id: this.request, paths, references: keys.map(item => references.get(item)!) }) } /** 文档换了版本:引用指到哪跟着源码变(加了定义、改了导入),下次 scan 照样再问。 */ @@ -137,19 +160,19 @@ export class RepoLinks { } /** id 那次询问的回答。不是最近一次的丢掉,返回这次有没有用上。 */ - checked(id: number, missing: readonly string[], unresolved: readonly string[]): boolean { + checked(id: number, missing: readonly string[], unresolved: readonly SymbolReference[]): boolean { if (id !== this.request) return false this.missingPaths = new Set(missing) - this.unresolved = new Set(unresolved) + this.unresolved = new Set(unresolved.map(referenceKey)) return true } - /** 扩展端打开时发现文件或符号没有;没有路径的是不带路径的引用找不到。 */ - broken(path: string | undefined, symbol?: string): void { + /** 扩展端打开时发现文件或符号没有;没有路径的是写在 block 那块里的不带路径的引用找不到。 */ + broken({ path, symbol, block }: ReportedLink): void { if (path === undefined) { if (symbol !== undefined) - this.unresolved.add(symbol) + this.unresolved.add(referenceKey({ symbol, block })) return } if (symbol !== undefined) @@ -158,11 +181,11 @@ export class RepoLinks { this.missingPaths.add(path) } - /** 打开成功:这个路径在,这个符号也在;没有路径的是不带路径的引用找到了。 */ - opened(path: string | undefined, symbol?: string): void { + /** 打开成功:这个路径在,这个符号也在;没有路径的是写在 block 那块里的不带路径的引用找到了。 */ + opened({ path, symbol, block }: ReportedLink): void { if (path === undefined) { if (symbol !== undefined) - this.unresolved.delete(symbol) + this.unresolved.delete(referenceKey({ symbol, block })) return } this.missingPaths.delete(path) @@ -170,13 +193,12 @@ export class RepoLinks { this.missingSymbols.delete(symbolKey(path, symbol)) } - /** 这个地址现在是不是坏的(文件没有,这个符号没有,或引用找不到)。还没问到的不算。 */ - isBroken(href: string): boolean { - const link = parseCommentLink(href) + /** 这个链接现在是不是坏的(文件没有,这个符号没有,或写在这块里的引用找不到)。还没问到的不算。 */ + isBroken(link: CommentLink | undefined): boolean { if (!link) return false if (link.path === undefined) - return this.unresolved.has(link.symbol) + return this.unresolved.has(referenceKey(link)) if (this.missingPaths.has(link.path)) return true return link.symbol !== undefined && this.missingSymbols.has(symbolKey(link.path, link.symbol)) diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 99b3259..1293938 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -184,6 +184,16 @@ export type BlockView = BlockViewBase & ({ translation: PreviewText, table?: Pre export interface TranslatorOption { id: string, label: string } +/** + * 注释里一个不带路径的引用(`[Name]`、`[Class.member]`):symbol 是写的名字;block 是这段注释属于哪一块(块的 anchor), + * 扩展端从那块代码的声明起一层层往外找。不在哪块的注释里的没有 block,只从文件顶层找。 + * 同一个名字写在不同块的注释里,指到的可以不一样。 + */ +export interface SymbolReference { + symbol: string + block?: string +} + /** 这份文件里出现过的一个属性值:显示名和有几块是这个值 */ export interface AttributeValueCount { id: string @@ -472,9 +482,10 @@ export type OpenedFrom = 'uncommitted' | 'other' * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 * diff:这份文件相比对比基准改了什么(审查工作副本里的、从「未提交的更改」点开的有改动的文件才有);每来一份文档之后补来,基准变了、换了从哪儿打开再来;null 是不比了 * openedFrom:这个标签页是从哪儿打开的,记进页面状态;ready 后来一次,开着时又从侧栏点开、换了从哪儿打开的再来 - * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用 - * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到 - * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了 + * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用(原样带回问的那几条) + * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到, + * block 同 openRepoLink + * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了,block 同 openRepoLink * repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在 * callTreeCounts:version 这份文档里,每个能看调用树的块落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理 * callTree:anchor 那块的调用树。trees 空着、又没有 contractMiss 是没有调用它的地方;接口卡片在契约文件里没对上时带 contractMiss。 @@ -538,9 +549,9 @@ export type ExtensionToWebview | RewriteEnd | { type: 'diff', diff: DocumentDiff | null } | { type: 'openedFrom', from: OpenedFrom } - | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] } - | { type: 'repoLinkBroken', path?: string, symbol?: string } - | { type: 'repoLinkOpened', path?: string, symbol?: string } + | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: SymbolReference[] } + | { type: 'repoLinkBroken', path?: string, symbol?: string, block?: string } + | { type: 'repoLinkOpened', path?: string, symbol?: string, block?: string } | { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] } | { type: 'focusPathAsk' } | { type: 'callTreeCounts', version: number, counts: Record } @@ -554,7 +565,7 @@ export type ExtensionToWebview * version 是这份下标所属的文档版本 * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 * peekDefinition:鼠标停在带下标的名字上,要 offset 处名字的定义卡片(几处定义取第一处);id、version 同 probeDefinition - * peekLink:鼠标停在注释里指到符号的链接上(带 #符号 的仓库链接、不带路径的引用),要那个符号的定义卡片;path、symbol 同 openRepoLink + * peekLink:鼠标停在注释里指到符号的链接上(带 #符号 的仓库链接、不带路径的引用),要那个符号的定义卡片;path、symbol、block 同 openRepoLink * findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition * setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示 * setOnlyChanges:打开 / 关掉「只看改动」;同样等扩展端发回 filter 再按它显示 @@ -570,8 +581,9 @@ export type ExtensionToWebview * selectProfile:kind 那种对话换 profile;不在本地先改,等扩展端发回这种的 profiles 再按它显示 * occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发 * openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录; - * 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),扩展端按这份文件找 symbol - * checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(symbols)问哪些找不到。id 标出是哪一次问 + * 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),扩展端按这份文件找 symbol:block 是它写在哪块的注释里(见 SymbolReference), + * 从那块的声明起一层层往外找 + * checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(references)问哪些找不到。id 标出是哪一次问 * checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问 * openCallTree:打开 anchor 那块的调用树。version 是这块所在的文档版本 * goToCallNode:点了调用树上的一个节点,打开 uri 那个文件,跳到 line 行 character 列(见 CallTreeNode.jump) @@ -590,15 +602,15 @@ export type WebviewToExtension | { type: 'probeDefinition', id: number, version: number, offset: number } | { type: 'goToDefinition', version: number, offset: number } | { type: 'peekDefinition', id: number, version: number, offset: number } - | { type: 'peekLink', id: number, path?: string, symbol: string } + | { type: 'peekLink', id: number, path?: string, symbol: string, block?: string } | { type: 'findOccurrences', id: number, version: number, offset: number } | { type: 'ask', askId: string, anchor: string, question: string, images: AskImage[] } | { type: 'stopAsk', askId: string } | { type: 'deleteQa', anchor: string, entryId: string } | { type: 'selectProfile', kind: ChatKind, id: string } | { type: 'occurrenceNavigation', available: boolean } - | { type: 'openRepoLink', path?: string, symbol?: string } - | { type: 'checkRepoLinks', id: number, paths: string[], symbols: string[] } + | { type: 'openRepoLink', path?: string, symbol?: string, block?: string } + | { type: 'checkRepoLinks', id: number, paths: string[], references: SymbolReference[] } | { type: 'checkRepoImages', id: number, paths: string[] } | { type: 'openCallTree', version: number, anchor: string } | { type: 'goToCallNode', uri: string, line: number, character: number } diff --git a/vscode/webview-ui/src/codeReader/useDefinitionPeek.ts b/vscode/webview-ui/src/codeReader/useDefinitionPeek.ts index 85ce681..c5b254a 100644 --- a/vscode/webview-ui/src/codeReader/useDefinitionPeek.ts +++ b/vscode/webview-ui/src/codeReader/useDefinitionPeek.ts @@ -1,6 +1,7 @@ import type { PeekTarget, ShownPeek } from './definitionPeek' import { useEffect, useState } from 'react' import { DefinitionPeeks, documentNameAt, inPeek, linkPeekSource } from './definitionPeek' +import { commentLinkOf } from './repoLink' import { onMessage, postMessage } from './vscode' /** 指针下能弹定义卡片的一处:注释里指到符号的链接,或页面上这份文档带下标的名字;浮窗里的不算。 */ @@ -9,7 +10,7 @@ function peekTargetAt(target: EventTarget | null): PeekTarget | nul return null const link = target.closest('.cr-markdown a') if (link) { - const source = linkPeekSource(link.getAttribute('href') ?? '') + const source = linkPeekSource(commentLinkOf(link)) return source ? { anchor: link, source } : null } const name = documentNameAt(target) diff --git a/vscode/webview-ui/src/codeReader/useRepoLinks.ts b/vscode/webview-ui/src/codeReader/useRepoLinks.ts index fcac91d..b005477 100644 --- a/vscode/webview-ui/src/codeReader/useRepoLinks.ts +++ b/vscode/webview-ui/src/codeReader/useRepoLinks.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { RepoLinks } from './repoLink' +import { commentLinkOf, RepoLinks } from './repoLink' import { onMessage, postMessage } from './vscode' /** 坏链接悬停上的字,走页面上统一的 data-tooltip,不用原生 title。 */ @@ -32,12 +32,13 @@ function paintLink(anchor: HTMLAnchorElement, broken: boolean): void { function paint(links: RepoLinks): void { for (const anchor of document.querySelectorAll('.cr-markdown a')) - paintLink(anchor, links.isBroken(anchor.getAttribute('href') ?? '')) + paintLink(anchor, links.isBroken(commentLinkOf(anchor))) } /** * 注释、说明、问 AI 的回答里的仓库路径链接,和注释里不带路径的引用:点了发给扩展端打开(不走 webview 把 http 链接交给外部打开的那条), * 画出来之后把路径、引用发去问哪些文件没有、哪些引用找不到;文档换了版本再问一次(引用跟着源码变)。 + * 不带路径的引用都带着它写在哪块(见 commentLinkOf),扩展端从那块的声明起找。 */ export function useRepoLinks(): void { const [links] = useState(() => new RepoLinks(postMessage)) @@ -47,10 +48,10 @@ export function useRepoLinks(): void { const scan = (): void => { window.clearTimeout(timer) timer = window.setTimeout(() => { - const anchors = [...document.querySelectorAll('.cr-markdown a')] - for (const anchor of anchors) - paintLink(anchor, links.isBroken(anchor.getAttribute('href') ?? '')) - links.scan(anchors.map(anchor => anchor.getAttribute('href') ?? '')) + const found = [...document.querySelectorAll('.cr-markdown a')].map(anchor => ({ anchor, link: commentLinkOf(anchor) })) + for (const { anchor, link } of found) + paintLink(anchor, links.isBroken(link)) + links.scan(found.flatMap(({ link }) => link ?? [])) }, 50) } const off = onMessage((msg) => { @@ -65,19 +66,19 @@ export function useRepoLinks(): void { return } if (msg.type === 'repoLinkBroken') { - links.broken(msg.path, msg.symbol) + links.broken(msg) paint(links) return } if (msg.type === 'repoLinkOpened') { - links.opened(msg.path, msg.symbol) + links.opened(msg) paint(links) } }) // 捕获阶段:赶在 webview 把 交给外部打开之前拦下仓库链接 const onClick = (event: MouseEvent): void => { const anchor = event.target instanceof Element ? event.target.closest('a') : null - if (!anchor || !links.click(anchor.getAttribute('href'))) + if (!anchor || !links.click(commentLinkOf(anchor))) return event.preventDefault() event.stopPropagation()