From c4113748e1e951ae63fcff4269e02dd79db73611 Mon Sep 17 00:00:00 2001 From: cruldra Date: Fri, 2 Oct 2026 03:17:36 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E4=BB=93=E5=BA=93=E8=B7=AF=E5=BE=84=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E8=83=BD=E7=82=B9=E5=BC=80=E5=B9=B6=E8=B7=B3=E5=88=B0?= =?UTF-8?q?=E7=AC=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 5 +- vscode/src/codeReader/editor/messages.ts | 10 ++ vscode/src/codeReader/editor/previewEditor.ts | 121 ++++++++++++++-- vscode/src/codeReader/editor/repoPath.test.ts | 73 ++++++++++ vscode/src/codeReader/editor/repoPath.ts | 115 +++++++++++++++ vscode/webview-ui/src/codeReader/App.tsx | 2 + .../webview-ui/src/codeReader/codeReader.css | 7 + .../src/codeReader/markdown.test.ts | 10 +- vscode/webview-ui/src/codeReader/markdown.ts | 8 +- .../src/codeReader/repoLink.test.ts | 89 ++++++++++++ vscode/webview-ui/src/codeReader/repoLink.ts | 134 ++++++++++++++++++ vscode/webview-ui/src/codeReader/types.ts | 13 ++ .../webview-ui/src/codeReader/useRepoLinks.ts | 93 ++++++++++++ 13 files changed, 662 insertions(+), 18 deletions(-) create mode 100644 vscode/src/codeReader/editor/repoPath.test.ts create mode 100644 vscode/src/codeReader/editor/repoPath.ts create mode 100644 vscode/webview-ui/src/codeReader/repoLink.test.ts create mode 100644 vscode/webview-ui/src/codeReader/repoLink.ts create mode 100644 vscode/webview-ui/src/codeReader/useRepoLinks.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index a8d7b07..31a4988 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -441,7 +441,8 @@ - 用在:注释块(`#`、`//`、`/* */`、不挂在函数、类上的 JSDoc、Python 模块开头的 docstring 和别的当注释用的单独字符串)的预览,函数、类、常量、变量属性表的「说明」,字段译文下面的说明。视图数据里它们是 `{ markdown }`(见「通用契约」的 `Markdown`),webview 按数据决定怎么渲染,不猜。 - 原文:翻译插件去掉注释符号(`#`、`//`、`/*` `*/` 和每行开头的 `*`)、引号和中文名记号(`SourceDocument.markers`,见「中文名」),再去掉行尾空白、首尾空行和共同缩进,保留换行,不做别的转换。一个注释节点(块注释、docstring)的第一行紧跟在注释符号 / 引号后面,不参与算共同缩进(同 PEP 257);几条行注释连成的一块每行地位相同,第一行也算,缩进四格的代码块才留得住。 - 渲染:webview 用 markdown-it(`html: false`、`linkify: true`),CommonMark 加 GFM 的表格、删除线:段落、强调、行内代码(含双反引号)、代码块(围栏、缩进四格)、列表、链接、标题、引用、表格。段内单个换行是软换行,空行分段。同一段文字只渲染一次(缓存最近 500 段)。 -- 安全:源码里的 HTML 当文字显示;链接只认 http / https / mailto,带协议的网址和邮箱自动成链接(`sample.py` 这种不带协议的不算),别的写法(`javascript:`、`command:`、相对路径)照原文显示。点链接由 VS Code 的 webview 自己拦下、交给宿主在外部打开,不用另发消息。图片不渲染(webview 的 CSP 不放行外部图片),`![alt](url)` 显示成 `!` 加链接。 +- 安全:源码里的 HTML 当文字显示;链接认 http / https / mailto,带协议的网址和邮箱自动成链接(`sample.py` 这种不带协议的不算),别的写法(`javascript:`、`command:`、`./`、`..`、绝对路径、盘符)照原文显示。http / https / mailto 由 VS Code 的 webview 自己拦下、交给宿主在外部打开,不用另发消息。图片不渲染(webview 的 CSP 不放行外部图片),`![alt](url)` 显示成 `!` 加链接。 +- 仓库路径链接(`[名字](从仓库根起的路径)`,可带 `#类名` 或 `#类名.成员`)能点,点了打开文件并跳到符号。 - 样子:正文用界面字体(`--vscode-font-family`、`--vscode-font-size`)、comment 色、行高 20px,一行注释和一行译文一样高;行内代码用编辑器字体、`--vscode-textCodeBlock-background` 底;围栏代码块用编辑器字体和字号、同样的底,长行折行;段落、列表、代码块、表格之间 6px;标题只比正文稍大(h1 1.15em、h2 1.08em,其余同正文,加粗);链接 `--vscode-textLink-foreground`;引用左边 3px `--vscode-textBlockQuote-border` 竖线、`--vscode-textBlockQuote-background` 底;表格 1px 细线。 - 「编辑」状态照旧是源码,编辑写回不变;Markdown 里的名字不能点,不参与跳到定义。 @@ -2000,7 +2001,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 输入参数表格:函数的「输入参数」行带列名 名称 / 类型 / 默认值(TS、Python 一样),默认值格子只有译文、没有就是空格子,别的行没有列名;webview 按有没有列名选表格还是列表,表格的格子按列数取,列宽模板(`tableTemplate`)只看列数。 - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。 -- 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / 相对路径的链接和图片不渲染;跳到定义不认 Markdown 里的名字。 +- 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / `./` / `..` / 绝对路径的链接和图片不渲染,仓库路径链接能点、点了打开文件并跳到符号;跳到定义不认 Markdown 里的名字。 - 行替换:多行块替换后缩进正确。 - 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。 - 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,讲别处名字的段(`refers`)按那个名字算、自己照抄的位置不算(`occurrences.test.ts` 的 `sourceOf`),点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。键盘跳(`occurrences.test.ts`、`reveal.test.ts`、`editor/occurrenceNavigation.test.ts`):回答照源码先后排、点中的那处是当前那一处、「2/5」和「N 处」;往后 / 往前、到头接着跳,找不到的跳过、总数照旧、一处都找不到不动,点中的不在回答里从点中的地方数起;只有当前那一处描框;能不能跳(高亮、焦点、输入框)第一次必报、之后变了才报;页面上找第一处显示着它的地方、要展开的块 / 属性行 / 子行、跳过筛掉 / 编辑 / 盖住的块、Markdown 不算;`package.json` 的两条命令、默认键、`when`、命令面板,扩展和页面的代码里不写键、不拦键,上下文键有一个预览能跳就真、变了才设,命令发给能跳的预览、没有就发给活动的;按键那一刻的位置跳转时回一次、隔太久 / 点过鼠标 / 没按过键不回,跳完守住跳到的位置、过了 `KEY_SCROLL_MS` 或又按键、点鼠标不守。 diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index ba7e2c3..cae646c 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -95,6 +95,9 @@ export interface QaMessage { * displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理 * diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有,见 DiffBase):每发一份文档之后算好了补发, * 换了筛选、基准挪了(提交后 HEAD 动了)再发;null 是不比了(回到全部文件) + * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径 + * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有 + * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在 */ export type ExtensionToWebview = | DocumentMessage @@ -111,6 +114,9 @@ export type ExtensionToWebview | { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string } | { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string } | { type: 'diff', diff: DocumentDiff | null } + | { type: 'repoLinksChecked', id: number, missing: string[] } + | { type: 'repoLinkBroken', path: string, symbol?: string } + | { type: 'repoLinkOpened', path: string, symbol?: string } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) @@ -130,6 +136,8 @@ export type ExtensionToWebview * selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览 * occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发; * 扩展端按它设 when 子句用的上下文键 + * openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在资源管理器里定位目录 + * checkRepoLinks:预览里这批仓库路径,问哪些文件或目录没有。id 标出是哪一次问 */ export type WebviewToExtension = | { type: 'ready' } @@ -148,3 +156,5 @@ export type WebviewToExtension | { type: 'deleteQa', anchor: string, entryId: string } | { type: 'selectAskProfile', id: string } | { type: 'occurrenceNavigation', available: boolean } + | { type: 'openRepoLink', path: string, symbol?: string } + | { type: 'checkRepoLinks', id: number, paths: string[] } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 24e3a0f..c0b6d3b 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,4 +1,4 @@ -import type { CustomTextEditorProvider, Disposable, Event, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode' +import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode' import type { ClaudeImage } from '../../cc/spawnClaude' import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, WorkspaceIndex } from '../contract' import type { DiffBase } from '../diff/diffBase' @@ -13,7 +13,7 @@ import type { BlockFilter } from './blockFilter' import type { Definition, FoundLocation } from './definitions' import type { ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages' import { dirname } from 'node:path' -import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode' +import { commands, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode' import { logger } from '../../logging/logger' import { webviewHtml, webviewUiRoot } from '../../webview/html' import { DocumentCallees } from '../calleeKinds' @@ -32,6 +32,7 @@ import { DefinitionSources } from './definitionSources' import { documentMessage } from './documentMessage' import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation' import { occurrencesOf } from './occurrences' +import { findRepoSymbol, repoRootOf, resolveRepoEntry } from './repoPath' export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview' @@ -491,6 +492,12 @@ class Preview { case 'findOccurrences': await this.findOccurrences(msg.id, msg.version, msg.offset) return + case 'openRepoLink': + await this.openRepoLink(msg.path, msg.symbol) + return + case 'checkRepoLinks': + await this.checkRepoLinks(msg.id, msg.paths) + return case 'occurrenceNavigation': this.owner.navigation.update(this, msg.available) } @@ -561,6 +568,84 @@ class Preview { await this.owner.openAt(uri, reveal, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options)) } + /** 预览里这批仓库路径,哪些文件或目录不在。 */ + private async checkRepoLinks(id: number, paths: string[]): Promise { + const root = await repoRootOf(this.document.uri.fsPath) + const missing = (await Promise.all(paths.map(async (rel) => { + if (root === undefined || await resolveRepoEntry(root, rel) === undefined) + return rel + return undefined + }))).filter(rel => rel !== undefined) + if (this.ready) + this.post({ type: 'repoLinksChecked', id, missing }) + } + + /** + * 打开注释里的仓库链接:目录在资源管理器里定位,文件用编辑器打开。 + * 带了符号就按文档符号的名字跳过去;文件或符号没有就不打开。 + */ + private async openRepoLink(rel: string, symbol?: string): Promise { + const root = await repoRootOf(this.document.uri.fsPath) + const target = root === undefined ? undefined : await resolveRepoEntry(root, rel) + if (!target) { + this.markRepoLinkBroken(rel, symbol) + return + } + const uri = Uri.file(target.fsPath) + try { + if (target.isDirectory) { + if (symbol !== undefined) { + this.markRepoLinkBroken(rel, symbol) + return + } + await commands.executeCommand('revealInExplorer', uri) + this.markRepoLinkOpened(rel) + return + } + if (symbol === undefined) { + await this.showRepoFile(uri) + this.markRepoLinkOpened(rel) + return + } + const found = await this.locateRepoSymbol(uri, symbol) + if (!found) { + this.markRepoLinkBroken(rel, symbol) + return + } + await this.showRepoFile(uri, found.selectionRange) + this.markRepoLinkOpened(rel, symbol) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.add({ level: 'warn', source: 'codeReader', message: `没能打开仓库链接 ${rel}:${message}` }) + void window.showErrorMessage(`代码阅读:没能打开 ${rel}`) + } + } + + /** 用文档符号按名字找。先把文件读进来(不显示),没有这个名字就没有,不打开到文件顶上。 */ + private async locateRepoSymbol(uri: Uri, symbol: string): Promise { + const doc = await workspace.openTextDocument(uri) + const symbols = await commands.executeCommand('vscode.executeDocumentSymbolProvider', doc.uri) + return Array.isArray(symbols) ? findRepoSymbol(symbols, symbol) : undefined + } + + /** 在当前这个预览所在的编辑器组打开文件,并把预览标签固定住,免得被它替掉。 */ + private async showRepoFile(uri: Uri, selection?: Range): Promise { + if (this.panel.active && !workspace.getConfiguration('workbench.editor').get('enablePreviewFromCodeNavigation', false)) + await commands.executeCommand('workbench.action.keepEditor') + await window.showTextDocument(uri, { viewColumn: this.panel.viewColumn, ...(selection && { selection }) }) + } + + private markRepoLinkBroken(path: string, symbol?: string): void { + if (this.ready) + this.post({ type: 'repoLinkBroken', path, ...(symbol !== undefined && { symbol }) }) + } + + private markRepoLinkOpened(path: string, symbol?: string): void { + if (this.ready) + this.post({ type: 'repoLinkOpened', path, ...(symbol !== undefined && { symbol }) }) + } + /** 用写注释的技能重写这一段,只把正文套回原来的定界符,再走块编辑那条写回。 */ private async rewriteComment(anchor: string): Promise { if (this.rewriting.has(anchor) || this.rewriteAbort.signal.aborted) @@ -592,27 +677,39 @@ class Preview { return { status: 'failed', error: message } } const found = this.sent && findPlaced(this.sent.blocks, anchor) - if (!found || found.block.kind !== 'comment') + if (!found) return failed('这块注释刚改过,找不到了。等预览刷新后再重写。') const { block, siblings, index } = found + const startLine = block.commentStartLine + const endLine = block.commentEndLine + if (startLine === undefined || endLine === undefined) + return failed('这块没有能重写的注释。') const { document } = this - if (block.startLine < 0 || block.endLine < block.startLine || block.endLine >= document.lineCount) + if (startLine < 0 || endLine < startLine || endLine >= document.lineCount) return failed('这块注释的位置已经变了,没有写回。') - const raw = Array.from({ length: block.endLine - block.startLine + 1 }, (_, i) => document.lineAt(block.startLine + i).text) + const linesOf = (from: number, to: number): string => Array.from({ length: to - from + 1 }, (_, i) => document.lineAt(from + i).text).join('\n') + const raw = linesOf(startLine, endLine).split('\n') const snapshot = raw.join('\n') const comment = commentSource(raw) const source = document.getText() + const attached = startLine !== block.startLine || endLine !== block.endLine + ? [ + ...(startLine > block.startLine ? [linesOf(block.startLine, startLine - 1)] : []), + ...(endLine < block.endLine ? [linesOf(endLine + 1, block.endLine)] : []), + ].filter(part => part !== '').join('\n') + : undefined const next = siblings[index + 1] - const following = next && next.startLine >= 0 && next.endLine >= next.startLine && next.endLine < document.lineCount - ? Array.from({ length: next.endLine - next.startLine + 1 }, (_, i) => document.lineAt(next.startLine + i).text).join('\n') + const following = attached === undefined && next && next.startLine >= 0 && next.endLine >= next.startLine && next.endLine < document.lineCount + ? linesOf(next.startLine, next.endLine) : undefined const prompt = commentRewritePrompt({ comment, source, - startLine: block.startLine, - endLine: block.endLine, + startLine, + endLine, earlierKinds: siblings.slice(0, index).map(item => item.kind), ...(following !== undefined && { following }), + ...(attached !== undefined && attached !== '' && { attached }), }) if (!prompt) return failed('这段注释的定界符认不出来,没有改文件。') @@ -666,9 +763,9 @@ class Preview { const body = modelCommentBody(answerText) if (!body) return failed('模型没有给出注释正文,没有改文件。') - if (block.endLine >= document.lineCount) + if (endLine >= document.lineCount) return failed('这段注释在重写期间变了,没有写回。') - const current = Array.from({ length: block.endLine - block.startLine + 1 }, (_, i) => document.lineAt(block.startLine + i).text).join('\n') + const current = linesOf(startLine, endLine) if (current !== snapshot) return failed('这段注释在重写期间变了,没有写回。') const filled = refillComment(comment, body) @@ -676,7 +773,7 @@ class Preview { return failed('这段注释的定界符认不出来,没有改文件。') if (signal.aborted) return { status: 'stopped' } - const wrote = await this.applyEdit(block.startLine, block.endLine, filled) + const wrote = await this.applyEdit(startLine, endLine, filled) if (wrote === 'written' || wrote === 'unchanged') return { status: 'done' } if (wrote === 'rejected') diff --git a/vscode/src/codeReader/editor/repoPath.test.ts b/vscode/src/codeReader/editor/repoPath.test.ts new file mode 100644 index 0000000..0624565 --- /dev/null +++ b/vscode/src/codeReader/editor/repoPath.test.ts @@ -0,0 +1,73 @@ +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { findRepoSymbol, pathInsideRoot, resolveRepoEntry } from './repoPath' + +describe('pathInsideRoot', () => { + const root = '/repo' + + it('拼在仓库根里', () => { + expect(pathInsideRoot(root, 'backend/src/a.py')).toBe('/repo/backend/src/a.py') + expect(pathInsideRoot(root, 'vendor/agents')).toBe('/repo/vendor/agents') + }) + + it('..、绝对路径、盘符不算', () => { + expect(pathInsideRoot(root, '../etc/passwd')).toBeUndefined() + expect(pathInsideRoot(root, 'foo/../../etc/passwd')).toBeUndefined() + expect(pathInsideRoot(root, '/etc/passwd')).toBeUndefined() + expect(pathInsideRoot(root, './x.md')).toBeUndefined() + expect(pathInsideRoot(root, 'C:/foo')).toBeUndefined() + }) +}) + +describe('findRepoSymbol', () => { + const tree = [ + { + name: 'Agent', + children: [ + { name: 'owner_id' }, + { name: 'Nested', children: [{ name: 'inner' }] }, + ], + }, + { name: 'load' }, + ] + + it('按类名、函数名找', () => { + expect(findRepoSymbol(tree, 'Agent')?.name).toBe('Agent') + expect(findRepoSymbol(tree, 'load')?.name).toBe('load') + }) + + it('类名.成员先找类,再在里面找成员', () => { + expect(findRepoSymbol(tree, 'Agent.owner_id')?.name).toBe('owner_id') + expect(findRepoSymbol(tree, 'Agent.inner')?.name).toBe('inner') + expect(findRepoSymbol(tree, 'Agent.missing')).toBeUndefined() + expect(findRepoSymbol(tree, 'Missing')).toBeUndefined() + }) +}) + +describe('resolveRepoEntry', () => { + it('文件和目录在仓库里,跑到外面的符号链接不算', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'repo-link-')) + const outside = await mkdtemp(path.join(tmpdir(), 'repo-link-out-')) + try { + await writeFile(path.join(root, 'ok.txt'), 'ok') + await mkdir(path.join(root, 'dir')) + await writeFile(path.join(outside, 'secret'), 'x') + await symlink(path.join(outside, 'secret'), path.join(root, 'leak')) + + const file = await resolveRepoEntry(root, 'ok.txt') + expect(file?.isDirectory).toBe(false) + expect(file?.fsPath).toBe(await realpath(path.join(root, 'ok.txt'))) + + expect((await resolveRepoEntry(root, 'dir'))?.isDirectory).toBe(true) + expect(await resolveRepoEntry(root, 'leak')).toBeUndefined() + expect(await resolveRepoEntry(root, 'missing')).toBeUndefined() + expect(await resolveRepoEntry(root, '../secret')).toBeUndefined() + } + finally { + await rm(root, { recursive: true, force: true }) + await rm(outside, { recursive: true, force: true }) + } + }) +}) diff --git a/vscode/src/codeReader/editor/repoPath.ts b/vscode/src/codeReader/editor/repoPath.ts new file mode 100644 index 0000000..b08d6fb --- /dev/null +++ b/vscode/src/codeReader/editor/repoPath.ts @@ -0,0 +1,115 @@ +import type { Stats } from 'node:fs' +import { realpath, stat } from 'node:fs/promises' +import path from 'node:path' +import { runGit } from '../../git/worktree' + +/** 文档符号树上的一个名字。只用来按名字找,不解析源码。 */ +export interface NamedSymbol { + name: string + children?: readonly NamedSymbol[] +} + +/** 仓库里的一个文件或目录。 */ +export interface RepoEntry { + fsPath: string + isDirectory: boolean +} + +/** + * 从仓库根起的相对路径。空段、`.`、`..`、绝对路径、盘符、反斜杠都不是。 + * 页面解析过一次,这里再查一遍:消息里的路径不能跑出仓库。 + */ +export function isSafeRepoPath(rel: string): boolean { + if (rel === '' || rel.startsWith('/') || rel.startsWith('./') || /^[a-z]:/i.test(rel) || hasControl(rel)) + return false + if (rel.includes('\\') || rel.includes('?') || rel.includes('#')) + return false + const segments = rel.split('/') + if (segments.at(-1) === '') + segments.pop() + return segments.length > 0 && segments.every(segment => segment !== '' && segment !== '.' && segment !== '..') +} + +/** target 解析之后还在 root 里面(root 自己不算)。 */ +export function staysInside(root: string, target: string): boolean { + const back = path.relative(path.resolve(root), path.resolve(target)) + return back !== '' && back !== '..' && !back.startsWith(`..${path.sep}`) && !path.isAbsolute(back) +} + +/** 把相对路径拼到仓库根上。跑出去了就没有。不看磁盘。 */ +export function pathInsideRoot(root: string, rel: string): string | undefined { + if (!isSafeRepoPath(rel)) + return undefined + const resolved = path.resolve(root, rel) + return staysInside(root, resolved) ? resolved : undefined +} + +/** 被预览的文件所在的 git 仓库根。不在仓库里就没有。 */ +export async function repoRootOf(filePath: string): Promise { + try { + const root = (await runGit(path.dirname(filePath), ['rev-parse', '--show-toplevel'])).trim() + return root === '' ? undefined : root + } + catch { + return undefined + } +} + +/** + * 仓库相对路径在磁盘上的文件或目录。 + * 解析之后(含符号链接的真实位置)必须仍在仓库根里面;没有这个路径就没有。 + */ +export async function resolveRepoEntry(root: string, rel: string): Promise { + const lexical = pathInsideRoot(root, rel) + if (!lexical) + return undefined + let rootReal: string + let info: Stats + let fileReal: string + try { + rootReal = await realpath(root) + info = await stat(lexical) + fileReal = await realpath(lexical) + } + catch { + return undefined + } + if (!staysInside(rootReal, fileReal)) + return undefined + return { fsPath: fileReal, isDirectory: info.isDirectory() } +} + +/** + * 在文档符号里按名字找。`Agent` 找这个类或函数;`Agent.owner_id` 先找 `Agent`,再在它里面找 `owner_id`。 + * 名字对的是符号自己的 name,不看源码怎么写。 + */ +export function findRepoSymbol(symbols: readonly T[], symbol: string): T | undefined { + const dot = symbol.indexOf('.') + const head = dot === -1 ? symbol : symbol.slice(0, dot) + const member = dot === -1 ? undefined : symbol.slice(dot + 1) + if (head === '' || member === '') + return undefined + const found = findNamed(symbols, head) + if (!found || member === undefined) + return found + return findNamed((found.children ?? []) as readonly T[], member) +} + +function findNamed(symbols: readonly T[], name: string): T | undefined { + for (const symbol of symbols) { + if (symbol.name === name) + return symbol + const nested = findNamed((symbol.children ?? []) as readonly T[], name) + if (nested) + return nested + } + return undefined +} + +function hasControl(text: string): boolean { + for (const char of text) { + if (char.charCodeAt(0) <= 31) + return true + } + return false +} diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index b8b83ea..b596d40 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -18,6 +18,7 @@ import { useChangeNav } from './useChangeNav' import { useDefinitionLinks } from './useDefinitionLinks' import { HighlightContext, useOccurrences } from './useOccurrences' import { useQa } from './useQa' +import { useRepoLinks } from './useRepoLinks' import { useReveal } from './useReveal' import { useRewrite } from './useRewrite' import { useScrollPosition } from './useScrollPosition' @@ -82,6 +83,7 @@ export function App(): ReactElement | null { useScrollPosition(doc) useReveal(doc, reveal, collapsed, unshown, covered, show, expand, revealed) useDefinitionLinks(doc?.version) + useRepoLinks() const nav = useChangeNav(stops, collapsed, expand) const occurrencePage = useMemo((): OccurrencePage | undefined => doc ? { diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 3211c6b..d1bd854 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -283,6 +283,13 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c color: var(--vscode-textLink-activeForeground); text-decoration: underline; } +/* 仓库链接指的文件或符号不在:红色波浪线,悬停「找不到」(data-tooltip) */ +.cr-markdown a.cr-link-broken, +.cr-markdown a.cr-link-broken:hover { + color: var(--vscode-errorForeground); + text-decoration: underline wavy; + text-underline-offset: 2px; +} .cr-markdown blockquote { padding: 0 8px; border-left: 3px solid var(--vscode-textBlockQuote-border); diff --git a/vscode/webview-ui/src/codeReader/markdown.test.ts b/vscode/webview-ui/src/codeReader/markdown.test.ts index 8905042..8905d3e 100644 --- a/vscode/webview-ui/src/codeReader/markdown.test.ts +++ b/vscode/webview-ui/src/codeReader/markdown.test.ts @@ -33,13 +33,21 @@ describe('renderMarkdown', () => { expect(renderMarkdown('改 sample.py 和 README.md')).toBe('

改 sample.py 和 README.md

\n') }) - it('链接只认 http / https / mailto,别的照原文显示;图片不渲染', () => { + it('危险链接和跑出仓库的路径照原文显示;图片不渲染', () => { expect(renderMarkdown('[点](javascript:alert(1))')).not.toContain(' { + expect(renderMarkdown('[智能体定义](vendor/agents)')).toBe('

智能体定义

\n') + expect(renderMarkdown('[智能体表](backend/src/luffy_agent/agent/models.py#Agent)')).toBe('

智能体表

\n') + expect(renderMarkdown('[owner 字段](backend/src/luffy_agent/agent/models.py#Agent.owner_id)')).toBe('

owner 字段

\n') + }) + it('链接的标题走悬停提示(data-tooltip),不写原生 title', () => { expect(renderMarkdown('[文档](https://example.com "接口说明")')).toBe('

文档

\n') expect(renderMarkdown('[文档](https://example.com "")')).toBe('

文档

\n') diff --git a/vscode/webview-ui/src/codeReader/markdown.ts b/vscode/webview-ui/src/codeReader/markdown.ts index 0ee7fcb..f0be7cd 100644 --- a/vscode/webview-ui/src/codeReader/markdown.ts +++ b/vscode/webview-ui/src/codeReader/markdown.ts @@ -1,12 +1,14 @@ import MarkdownIt from 'markdown-it' +import { parseRepoLink } from './repoLink' /** * 注释、函数和类说明、问 AI 的回答的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。 - * 源码里的 HTML 当文字显示;链接只认 http / https / mailto,别的写法照原文显示—— - * webview 会拦下 的点击交给 VS Code 在外部打开。图片不渲染:webview 的 CSP 不放行外部图片。 + * 源码里的 HTML 当文字显示。链接认 http / https / mailto,以及从仓库根起的路径(可带 #类名 或 #类名.成员); + * 别的写法照原文显示。webview 会拦下 http 链接的点击交给 VS Code 在外部打开;仓库路径链接由页面另发给扩展端,不走外部打开。 + * 图片不渲染:webview 的 CSP 不放行外部图片。 */ const md = new MarkdownIt({ html: false, linkify: true }).disable('image') -md.validateLink = url => /^(?:https?|mailto):/i.test(url) +md.validateLink = url => /^(?:https?|mailto):/i.test(url) || parseRepoLink(url) !== undefined /** 链接的标题(`[文字](网址 "标题")`)写成 data-tooltip,和页面上别的悬停提示走同一个提示框(components/ui/tooltip.tsx),不用原生 title。 */ md.core.ruler.push('link_title_tooltip', (state) => { diff --git a/vscode/webview-ui/src/codeReader/repoLink.test.ts b/vscode/webview-ui/src/codeReader/repoLink.test.ts new file mode 100644 index 0000000..0351929 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/repoLink.test.ts @@ -0,0 +1,89 @@ +import type { WebviewToExtension } from './types' +import { describe, expect, it } from 'vitest' +import { parseRepoLink, RepoLinks } from './repoLink' + +describe('parseRepoLink', () => { + it('合法路径', () => { + expect(parseRepoLink('vendor/agents')).toEqual({ path: 'vendor/agents' }) + expect(parseRepoLink('README.md')).toEqual({ path: 'README.md' }) + expect(parseRepoLink('vendor/agents/')).toEqual({ path: 'vendor/agents' }) + }) + + it('带类名', () => { + expect(parseRepoLink('backend/src/luffy_agent/agent/models.py#Agent')).toEqual({ + path: 'backend/src/luffy_agent/agent/models.py', + symbol: 'Agent', + }) + }) + + it('带类名.成员', () => { + expect(parseRepoLink('backend/src/luffy_agent/agent/models.py#Agent.owner_id')).toEqual({ + path: 'backend/src/luffy_agent/agent/models.py', + symbol: 'Agent.owner_id', + }) + expect(parseRepoLink('pkg/mod.py#__init__')).toEqual({ path: 'pkg/mod.py', symbol: '__init__' }) + }) + + it('行号不算', () => { + expect(parseRepoLink('file.py#123')).toBeUndefined() + expect(parseRepoLink('file.py#0')).toBeUndefined() + expect(parseRepoLink('file.py#Agent.owner.extra')).toBeUndefined() + }) + + it('.. 不算', () => { + expect(parseRepoLink('../etc/passwd')).toBeUndefined() + expect(parseRepoLink('foo/../../bar')).toBeUndefined() + expect(parseRepoLink('foo/%2e%2e/bar')).toBeUndefined() + expect(parseRepoLink('./x.md')).toBeUndefined() + expect(parseRepoLink('/etc/passwd')).toBeUndefined() + expect(parseRepoLink('C:/foo')).toBeUndefined() + }) + + it('http 不算', () => { + expect(parseRepoLink('https://example.com')).toBeUndefined() + expect(parseRepoLink('http://example.com/a')).toBeUndefined() + expect(parseRepoLink('mailto:dev@example.com')).toBeUndefined() + expect(parseRepoLink('javascript:alert(1)')).toBeUndefined() + }) +}) + +describe('repoLinks', () => { + function setup(): { links: RepoLinks, sent: WebviewToExtension[] } { + const sent: WebviewToExtension[] = [] + return { links: new RepoLinks(msg => sent.push(msg)), sent } + } + + it('点仓库链接发给扩展端,http 不管', () => { + const { links, sent } = setup() + expect(links.click('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) + }) + + 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'] }]) + + expect(links.checked(0, ['vendor/agents'])).toBe(false) + expect(links.isBroken('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) + }) + + 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) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/repoLink.ts b/vscode/webview-ui/src/codeReader/repoLink.ts new file mode 100644 index 0000000..d03dc39 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/repoLink.ts @@ -0,0 +1,134 @@ +import type { WebviewToExtension } from './types' + +/** 注释里指向仓库代码的链接:从仓库根起的路径,可选一个符号。 */ +export interface RepoLink { + /** 从仓库根算起,用 / 分隔,开头没有 / 或 ./ */ + path: string + /** 类名、函数名,或「类名.成员名」。指向目录或整个文件时没有 */ + symbol?: string +} + +const SCHEME = /^[a-z][a-z0-9+.-]*:/i +const IDENT = /^[\p{L}_$][\p{L}\p{N}_$]*$/u + +/** + * 从链接地址拆出仓库路径和可选符号。 + * 不是这种写法就没有:http / mailto、行号(`#123`)、带 `..`、绝对路径、盘符、以 `/` 或 `./` 开头。 + */ +export function parseRepoLink(url: string): RepoLink | undefined { + let text: string + try { + text = decodeURIComponent(url.trim()) + } + catch { + return undefined + } + if (text === '' || SCHEME.test(text) || text.startsWith('/') || text.startsWith('./') || text.startsWith('../') || text.includes('\\') || hasControl(text)) + return undefined + const hash = text.indexOf('#') + const rawPath = hash === -1 ? text : text.slice(0, hash) + const symbol = hash === -1 ? undefined : text.slice(hash + 1) + const path = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath + if (!isRepoPath(path)) + return undefined + if (symbol === undefined) + return { path } + if (!isSymbol(symbol)) + return undefined + return { path, symbol } +} + +function hasControl(text: string): boolean { + for (const char of text) { + if (char.charCodeAt(0) <= 31) + return true + } + return false +} + +function isRepoPath(path: string): boolean { + if (path === '' || path.includes('?')) + return false + return path.split('/').every(segment => segment !== '' && segment !== '.' && segment !== '..') +} + +function isSymbol(symbol: string): boolean { + const parts = symbol.split('.') + return (parts.length === 1 || parts.length === 2) && parts.every(part => IDENT.test(part)) +} + +function symbolKey(path: string, symbol: string): string { + return `${path}#${symbol}` +} + +/** + * 页面上的仓库链接:点了发给扩展端打开;预览出来后把路径发去问哪些文件没有; + * 扩展端说没有的,标成坏的。符号在不在要点了才知道。 + */ +export class RepoLinks { + private request = 0 + /** 最近一次问过的路径,没变就不再问 */ + private sent = '' + private missingPaths = new Set() + private missingSymbols = new Set() + + constructor(private readonly post: (msg: WebviewToExtension) => void) {} + + /** 点在仓库链接上:发给扩展端打开,返回 true。不是仓库链接就不管。 */ + click(href: string | null): boolean { + const link = href === null ? undefined : parseRepoLink(href) + if (!link) + return false + this.post({ type: 'openRepoLink', path: link.path, ...(link.symbol !== undefined && { symbol: link.symbol }) }) + return true + } + + /** 页面上现有的仓库链接。路径变了才问扩展端哪些没有。 */ + scan(hrefs: readonly string[]): void { + const paths = [...new Set(hrefs.flatMap((href) => { + const link = parseRepoLink(href) + return link ? [link.path] : [] + }))].sort() + const key = paths.join('\n') + if (key === this.sent) + return + this.sent = key + if (paths.length === 0) + return + this.request += 1 + this.post({ type: 'checkRepoLinks', id: this.request, paths }) + } + + /** id 那次询问的回答。不是最近一次的丢掉,返回这次有没有用上。 */ + checked(id: number, missing: readonly string[]): boolean { + if (id !== this.request) + return false + this.missingPaths = new Set(missing) + return true + } + + /** 扩展端打开时发现文件或符号没有。 */ + broken(path: string, symbol?: string): void { + if (symbol !== undefined) + this.missingSymbols.add(symbolKey(path, symbol)) + else + this.missingPaths.add(path) + } + + /** 打开成功:这个路径在,这个符号也在。 */ + opened(path: string, symbol?: string): void { + this.missingPaths.delete(path) + if (symbol !== undefined) + this.missingSymbols.delete(symbolKey(path, symbol)) + } + + /** 这个地址现在是不是坏的(文件没有,或这个符号没有)。还没问到的不算。 */ + isBroken(href: string): boolean { + const link = parseRepoLink(href) + if (!link) + return false + 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 effd2be..713deba 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -135,6 +135,9 @@ interface BlockViewBase { attributes: Record /** 这块认出来的概念,从左到右。webview 按图标名画,不自己用种类查 */ concepts: BlockConcept[] + /** 能重写的那段注释的起止行。注释块是整块;函数、类等是它的说明。没有就不显示重写按钮。 */ + commentStartLine?: number + commentEndLine?: number /** 库规则的卡片才有:卡片边框的颜色,筛选栏里这个种类的名字。和类块一样默认收起子块 */ badge?: KindBadge /** 库规则放进父卡片里的子块才有:画在父卡片属性表第 row 行下面,不排在父卡片的子块里;item 见 BlockSlot */ @@ -395,6 +398,9 @@ export interface DocumentDiff { * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 * diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有);每来一份文档之后补来,基准变了再来;null 是不比了 + * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径 + * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有 + * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在 */ export type ExtensionToWebview = | DocumentMessage @@ -411,6 +417,9 @@ export type ExtensionToWebview | { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string } | { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string } | { type: 'diff', diff: DocumentDiff | null } + | { type: 'repoLinksChecked', id: number, missing: string[] } + | { type: 'repoLinkBroken', path: string, symbol?: string } + | { type: 'repoLinkOpened', path: string, symbol?: string } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进) @@ -429,6 +438,8 @@ export type ExtensionToWebview * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) * selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示 * occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发 + * openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在资源管理器里定位目录 + * checkRepoLinks:预览里这批仓库路径,问哪些文件或目录没有。id 标出是哪一次问 */ export type WebviewToExtension = | { type: 'ready' } @@ -447,3 +458,5 @@ export type WebviewToExtension | { type: 'deleteQa', anchor: string, entryId: string } | { type: 'selectAskProfile', id: string } | { type: 'occurrenceNavigation', available: boolean } + | { type: 'openRepoLink', path: string, symbol?: string } + | { type: 'checkRepoLinks', id: number, paths: string[] } diff --git a/vscode/webview-ui/src/codeReader/useRepoLinks.ts b/vscode/webview-ui/src/codeReader/useRepoLinks.ts new file mode 100644 index 0000000..edc5f62 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/useRepoLinks.ts @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react' +import { RepoLinks } from './repoLink' +import { onMessage, postMessage } from './vscode' + +/** 坏链接悬停上的字,走页面上统一的 data-tooltip,不用原生 title。 */ +const MISSING = '找不到' + +/** 文件或符号没有的仓库链接:红色波浪线,悬停「找不到」。问到之前先是普通链接。 */ +function paintLink(anchor: HTMLAnchorElement, broken: boolean): void { + const tip = anchor.getAttribute('data-tooltip') + if (broken) { + anchor.classList.add('cr-link-broken') + if (tip !== MISSING) { + if (tip) + anchor.dataset.linkTooltip = tip + anchor.setAttribute('data-tooltip', MISSING) + } + return + } + if (!anchor.classList.contains('cr-link-broken')) + return + anchor.classList.remove('cr-link-broken') + if (tip === MISSING) { + const prev = anchor.dataset.linkTooltip + if (prev) + anchor.setAttribute('data-tooltip', prev) + else + anchor.removeAttribute('data-tooltip') + } + delete anchor.dataset.linkTooltip +} + +function paint(links: RepoLinks): void { + for (const anchor of document.querySelectorAll('.cr-markdown a')) + paintLink(anchor, links.isBroken(anchor.getAttribute('href') ?? '')) +} + +/** + * 注释、说明、问 AI 的回答里的仓库路径链接:点了发给扩展端打开(不走 webview 把 http 链接交给外部打开的那条), + * 画出来之后把路径发去问哪些文件没有。 + */ +export function useRepoLinks(): void { + const [links] = useState(() => new RepoLinks(postMessage)) + + useEffect(() => { + const off = onMessage((msg) => { + if (msg.type === 'repoLinksChecked') { + if (links.checked(msg.id, msg.missing)) + paint(links) + return + } + if (msg.type === 'repoLinkBroken') { + links.broken(msg.path, msg.symbol) + paint(links) + return + } + if (msg.type === 'repoLinkOpened') { + links.opened(msg.path, msg.symbol) + 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'))) + return + event.preventDefault() + event.stopPropagation() + } + let timer = 0 + 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') ?? '')) + }, 50) + } + const observer = new MutationObserver(scan) + observer.observe(document.body, { childList: true, subtree: true }) + document.addEventListener('click', onClick, true) + document.addEventListener('auxclick', onClick, true) + scan() + return () => { + off() + observer.disconnect() + document.removeEventListener('click', onClick, true) + document.removeEventListener('auxclick', onClick, true) + window.clearTimeout(timer) + } + }, [links]) +}