✨ feat(vscode): 注释里的仓库路径链接能点开并跳到符号

This commit is contained in:
cruldra committed 2026-10-03 03:22:20 +08:00
1 parent 5528a405cc
commit c4113748e1
13 files changed
+662 -18

No files matched your search

+3 -2
View File
@@ -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` 或又按键、点鼠标不守。
+10
View File
@@ -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[] }
+109 -12
View File
@@ -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<void> {
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<void> {
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<DocumentSymbol | undefined> {
const doc = await workspace.openTextDocument(uri)
const symbols = await commands.executeCommand<DocumentSymbol[] | undefined>('vscode.executeDocumentSymbolProvider', doc.uri)
return Array.isArray(symbols) ? findRepoSymbol(symbols, symbol) : undefined
}
/** 在当前这个预览所在的编辑器组打开文件,并把预览标签固定住,免得被它替掉。 */
private async showRepoFile(uri: Uri, selection?: Range): Promise<void> {
if (this.panel.active && !workspace.getConfiguration('workbench.editor').get<boolean>('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<void> {
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')
@@ -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 })
}
})
})
+115
View File
@@ -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<string | undefined> {
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<RepoEntry | undefined> {
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<T extends NamedSymbol>(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<T extends NamedSymbol>(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
}
+2
View File
@@ -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
? {
@@ -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);
@@ -33,13 +33,21 @@ describe('renderMarkdown', () => {
expect(renderMarkdown('改 sample.py 和 README.md')).toBe('<p>改 sample.py 和 README.md</p>\n')
})
it('链接只认 http / https / mailto,别的照原文显示;图片不渲染', () => {
it('危险链接和跑出仓库的路径照原文显示;图片不渲染', () => {
expect(renderMarkdown('[点](javascript:alert(1))')).not.toContain('<a')
expect(renderMarkdown('[命令](command:workbench.action.reloadWindow)')).not.toContain('<a')
expect(renderMarkdown('[相对](./x.md)')).not.toContain('<a')
expect(renderMarkdown('[上级](../secret)')).not.toContain('<a')
expect(renderMarkdown('[行号](file.py#123)')).not.toContain('<a')
expect(renderMarkdown('![图](https://example.com/a.png)')).not.toContain('<img')
})
it('仓库路径链接能点,带类名或类名.成员也一样', () => {
expect(renderMarkdown('[智能体定义](vendor/agents)')).toBe('<p><a href="vendor/agents">智能体定义</a></p>\n')
expect(renderMarkdown('[智能体表](backend/src/luffy_agent/agent/models.py#Agent)')).toBe('<p><a href="backend/src/luffy_agent/agent/models.py#Agent">智能体表</a></p>\n')
expect(renderMarkdown('[owner 字段](backend/src/luffy_agent/agent/models.py#Agent.owner_id)')).toBe('<p><a href="backend/src/luffy_agent/agent/models.py#Agent.owner_id">owner 字段</a></p>\n')
})
it('链接的标题走悬停提示(data-tooltip),不写原生 title', () => {
expect(renderMarkdown('[文档](https://example.com "接口说明")')).toBe('<p><a href="https://example.com" data-tooltip="接口说明">文档</a></p>\n')
expect(renderMarkdown('[文档](https://example.com "")')).toBe('<p><a href="https://example.com">文档</a></p>\n')
+5 -3
View File
@@ -1,12 +1,14 @@
import MarkdownIt from 'markdown-it'
import { parseRepoLink } from './repoLink'
/**
* 注释、函数和类说明、问 AI 的回答的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。
* 源码里的 HTML 当文字显示;链接只认 http / https / mailto,别的写法照原文显示——
* webview 会拦下 <a> 的点击交给 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) => {
@@ -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)
})
})
@@ -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<string>()
private missingSymbols = new Set<string>()
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))
}
}
+13
View File
@@ -135,6 +135,9 @@ interface BlockViewBase {
attributes: Record<string, string>
/** 这块认出来的概念,从左到右。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[] }
@@ -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<HTMLAnchorElement>('.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 把 <a> 交给外部打开之前拦下仓库链接
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<HTMLAnchorElement>('.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])
}