✨ 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

+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
}