Files
superwork/vscode/src/codeReader/editor/repoPath.ts
T
cruldra 880bf7fc57 ✨ feat(vscode): 新增「了解一块代码」:AI 找功能代码占一栏,对话里接着改
主栏标题栏的放大镜问要了解哪一块,占一个栏位建一栏(和收藏共用 30 个栏位,视图改名 slot1..30,按 slotKindN 分收藏 / 功能挂标题栏按钮),同时开对话页让 Claude 只读地在全部工作区文件夹里找,按 --json-schema 交回环节 → 文件 + 符号 + 一句话。
对话复用预览顶部的聊天(CodeQa.askTop:会话续接、进行中的一轮、记录存 workspaceState);结构不合格算这一轮没答成、留着能再画出来。
条目每次画、点时按眼下的代码找:有语言插件的解析成代码块按「类名.方法名」找,没有的问 VS Code 文档符号;找不到画成灰的。
2026-10-12 15:48:54 +11:00

125 lines
4.5 KiB
TypeScript

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 !== '..')
}
/** 注释里当图片显示的文件后缀。webview 的 repoImage.ts 用同一份。 */
const IMAGE_FILE = /\.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i
/** 从仓库根起、能在注释里当图片显示的路径。别的仓库文件不给预览地址。 */
export function isRepoImagePath(rel: string): boolean {
return isSafeRepoPath(rel) && IMAGE_FILE.test(rel)
}
/** 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`,点得更多的一层层往里找。
* 同名的有几个时(Rust 的 `struct Order` 和 `impl Order`)挨个试,取第一个里面找得到后面几段的。
* 名字对的是符号自己的 name,不看源码怎么写;有一段是空的(`Agent.`、`a..b`)找不到。
*/
export function findRepoSymbol<T extends NamedSymbol>(symbols: readonly T[], symbol: string): T | undefined {
const names = symbol.split('.')
return names.includes('') ? undefined : findPath(symbols, names)
}
/** 第一段按名字找(照先后、先往里再往后),每个同名的都在它里面接着找后面几段。 */
function findPath<T extends NamedSymbol>(symbols: readonly T[], [name, ...rest]: readonly string[]): T | undefined {
for (const found of named(symbols, name)) {
const target = rest.length === 0 ? found : findPath((found.children ?? []) as readonly T[], rest)
if (target)
return target
}
return undefined
}
function* named<T extends NamedSymbol>(symbols: readonly T[], name: string): Generator<T> {
for (const symbol of symbols) {
if (symbol.name === name)
yield symbol
yield* named((symbol.children ?? []) as readonly T[], name)
}
}
function hasControl(text: string): boolean {
for (const char of text) {
if (char.charCodeAt(0) <= 31)
return true
}
return false
}