主栏标题栏的放大镜问要了解哪一块,占一个栏位建一栏(和收藏共用 30 个栏位,视图改名 slot1..30,按 slotKindN 分收藏 / 功能挂标题栏按钮),同时开对话页让 Claude 只读地在全部工作区文件夹里找,按 --json-schema 交回环节 → 文件 + 符号 + 一句话。 对话复用预览顶部的聊天(CodeQa.askTop:会话续接、进行中的一轮、记录存 workspaceState);结构不合格算这一轮没答成、留着能再画出来。 条目每次画、点时按眼下的代码找:有语言插件的解析成代码块按「类名.方法名」找,没有的问 VS Code 文档符号;找不到画成灰的。
125 lines
4.5 KiB
TypeScript
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
|
|
}
|