✨ 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

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