✨ feat(vscode): 代码阅读「了解一块代码」改成按路径列调用树:结果是几条路径(用户的一种操作一条),每条从入口一路调到后端服务、数据表,跨进程的写明怎么连过去(via),结构用扁平的 nodes 加 parent 连成树、不用递归;侧栏路径下面一层层展开节点,回答里用嵌套列表画树;回答和找的过程里的代码位置写成仓库链接,功能对话页上的链接按找代码用的工作区文件夹检查、打开、悬停;旧格式的结果当没有,栏上方提示打开对话重新找;删功能前先弹模态框确认,说清连带删掉调用树和问答记录,点「删除」才删

This commit is contained in:
cruldra committed 2026-10-14 21:28:40 +11:00
1 parent cc445e7123
commit c6de46ad37
17 files changed
+978 -253

No files matched your search

@@ -6,10 +6,12 @@
import type { WorkspaceFolder } from 'vscode'
import type { QaLocation } from '../folderStore'
import type { NamedFolder, RepoPlace } from './repoPath'
import * as path from 'node:path'
import { Uri, workspace } from 'vscode'
import { locateQa } from '../folderStore'
import { featureQaKey, pathQaKey, relativeQaKey } from '../qa/pathQa'
import { folderLinkPlace, repoRootOf } from './repoPath'
export const PATH_PREVIEW_SCHEME = 'code-reader-path'
@@ -36,6 +38,27 @@ export function featureIdOf(uri: Uri): string | undefined {
return uri.scheme === PATH_PREVIEW_SCHEME && uri.query.startsWith(FEATURE_QUERY) ? uri.query.slice(FEATURE_QUERY.length) : undefined
}
/** 「了解一块代码」找代码用的工作区文件夹:眼下打开的全部,claude 在第一个里面跑,别的加进来读。 */
export function featureFolders(): NamedFolder[] {
return (workspace.workspaceFolders ?? []).map(folder => ({ name: folder.name, path: folder.uri.fsPath }))
}
/** 把页面上一条仓库链接的路径换成磁盘上的位置;算不出来的没有。 */
export type LinkBase = (rel: string) => RepoPlace | undefined
/**
* 预览这一页上仓库链接的路径从哪儿算:功能对话页从这个功能找代码用的工作区文件夹算(见 [featureFolders]、[folderLinkPlace]),
* 别的页从文档所在的 git 仓库根算,不在 git 仓库里时一条也算不出来。
*/
export async function linkBaseOf(uri: Uri): Promise<LinkBase> {
if (featureIdOf(uri) !== undefined) {
const folders = featureFolders()
return rel => folderLinkPlace(folders, rel)
}
const root = await repoRootOf(uri.fsPath)
return rel => root === undefined ? undefined : { root, rel }
}
export function pathPreviewUri(target: Uri, kind: PathSubject): Uri {
return target.with({ scheme: PATH_PREVIEW_SCHEME, query: kind, fragment: '' })
}
@@ -45,7 +45,11 @@ vi.mock('vscode', () => ({
vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } }))
vi.mock('../../webview/html', () => ({ webviewHtml: () => '', webviewUiRoot: () => ({}) }))
vi.mock('../folderStore', () => ({ locate: () => ({ folder: { uri: { fsPath: '/workspace' } }, key: 'web/a.ts' }) }))
vi.mock('./pathPreview', () => ({ PATH_PREVIEW_SCHEME: 'code-reader-path', topSubjectOf: (uri: unknown) => ({ uri, kind: 'file' }) }))
/** 顶部问答按文件算;仓库链接从哪儿算照真的来(功能对话页按工作区文件夹,别的按仓库根) */
vi.mock('./pathPreview', async importOriginal => ({
...await importOriginal<typeof import('./pathPreview')>(),
topSubjectOf: (uri: unknown) => ({ uri, kind: 'file' }),
}))
vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() }))
/** 排视图数据不真的解析:一份没有块的文档 */
vi.mock('./documentMessage', async importOriginal => ({
@@ -66,11 +70,15 @@ vi.mock('./definitions', async importOriginal => ({
...await importOriginal<typeof import('./definitions')>(),
languageDefinitions: vi.fn(async () => []),
}))
/** 仓库在 /workspace,里面只有这几个文件 */
/** 仓库在 /workspace;磁盘上只有这几个文件(另两个在「了解一块代码」用的工作区文件夹 /ws/web、/srv/app 里) */
vi.mock('./repoPath', async importOriginal => ({
...await importOriginal<typeof import('./repoPath')>(),
repoRootOf: async () => '/workspace',
resolveRepoEntry: async (_root: string, rel: string) => ['web/a.ts', 'web/b.ts', 'docs/notes.txt'].includes(rel) ? { fsPath: `/workspace/${rel}`, isDirectory: false } : undefined,
resolveRepoEntry: async (root: string, rel: string) => {
const fsPath = `${root}/${rel}`
const files = ['/workspace/web/a.ts', '/workspace/web/b.ts', '/workspace/docs/notes.txt', '/ws/web/src/app/(main)/page.tsx', '/srv/app/app/routes.py']
return files.includes(fsPath) ? { fsPath, isDirectory: false } : undefined
},
}))
const SOURCE = ['// 旧说明', 'export function load() {}']
@@ -698,6 +706,77 @@ describe('点了带几行的仓库链接', () => {
})
})
describe('「了解一块代码」对话页上的仓库链接按这个功能找代码用的工作区文件夹算', () => {
/** 功能对话页:路径是问的那句话,不在磁盘上,也不在哪个 git 仓库里 */
const FEATURE_PAGE = { scheme: 'code-reader-path', query: 'feature=f1', path: '/用户如何聊天', fsPath: '/用户如何聊天', toString: () => 'code-reader-path:/用户如何聊天?feature=f1' }
const WEB = { name: 'web', fsPath: '/ws/web' }
const SERVER = { name: 'server', fsPath: '/srv/app' }
async function featurePage(folders: { name: string, fsPath: string }[]) {
Object.assign(workspace, { workspaceFolders: folders.map(({ name, fsPath }) => ({ name, uri: { fsPath } })) })
vi.mocked(commands.executeCommand).mockClear()
vi.mocked(workspace.openTextDocument).mockImplementation((async (uri: { fsPath: string }) => ({ uri, languageId: 'python', lineCount: 20, lineAt: () => ({ text: '' }), positionAt: (offset: number) => ({ line: 0, character: offset }) })) as never)
const { provider, document } = opening()
Object.assign(document, { uri: FEATURE_PAGE, fileName: FEATURE_PAGE.fsPath, languageId: 'plaintext', getText: () => '' })
const page = tab()
provider.resolveCustomTextEditor(document as never, page.panel)
await page.send({ type: 'ready' })
page.clear()
return page
}
function reset(): void {
Object.assign(workspace, { workspaceFolders: undefined })
vi.mocked(workspace.openTextDocument).mockReset()
}
it('几个工作区文件夹:路径第一段是文件夹名,按那个文件夹查哪些不在;不再全判成找不到', async () => {
try {
const page = await featurePage([WEB, SERVER])
await page.send({ type: 'checkRepoLinks', id: 1, paths: ['web/src/app/(main)/page.tsx', 'server/app/routes.py', 'server/app/gone.py', 'other/a.py'], references: [] })
expect(page.posted()).toEqual([{ type: 'repoLinksChecked', id: 1, missing: ['server/app/gone.py', 'other/a.py'], unresolved: [], builtin: [] }])
}
finally {
reset()
}
})
it('只有一个工作区文件夹:路径相对它,带文件夹名的反倒不认', async () => {
try {
const page = await featurePage([WEB])
await page.send({ type: 'checkRepoLinks', id: 1, paths: ['src/app/(main)/page.tsx', 'web/src/app/(main)/page.tsx'], references: [] })
expect(page.posted()).toEqual([{ type: 'repoLinksChecked', id: 1, missing: ['web/src/app/(main)/page.tsx'], unresolved: [], builtin: [] }])
}
finally {
reset()
}
})
it('点链接按同一个算法打开文件;带符号的点了、悬停都在那个文件的文档符号里找', async () => {
try {
const page = await featurePage([WEB, SERVER])
await page.send({ type: 'openRepoLink', path: 'server/app/routes.py' })
expect(commands.executeCommand).toHaveBeenCalledWith('vscode.open', expect.objectContaining({ fsPath: '/srv/app/app/routes.py' }), expect.anything())
expect(page.posted()).toEqual([{ type: 'repoLinkOpened', path: 'server/app/routes.py' }])
vi.mocked(documentSymbolNodes).mockReset().mockResolvedValue([
{ name: 'HomePage', range: { startIndex: 0, endIndex: 40 }, selection: { startIndex: 16, endIndex: 24 }, children: [] },
])
page.clear()
await page.send({ type: 'peekLink', id: 2, path: 'web/src/app/(main)/page.tsx', symbol: 'HomePage' })
expect(documentSymbolNodes).toHaveBeenCalledWith('file:///ws/web/src/app/(main)/page.tsx')
await page.send({ type: 'openRepoLink', path: 'web/src/app/(main)/page.tsx', symbol: 'HomePage' })
expect(page.posted()).toContainEqual({ type: 'repoLinkOpened', path: 'web/src/app/(main)/page.tsx', symbol: 'HomePage' })
await page.send({ type: 'openRepoLink', path: 'web/src/app/(main)/page.tsx', symbol: 'Gone' })
expect(page.posted()).toContainEqual({ type: 'repoLinkBroken', path: 'web/src/app/(main)/page.tsx', symbol: 'Gone' })
}
finally {
vi.mocked(documentSymbolNodes).mockReset().mockResolvedValue([])
reset()
}
})
})
describe('预览里的查找栏', () => {
const sent = (page: ReturnType<typeof tab>): ExtensionToWebview[] => page.posted().filter(msg => msg.type === 'openFind' || msg.type === 'moveFind')
+19 -18
View File
@@ -17,7 +17,8 @@ import type { BlockFilter } from './blockFilter'
import type { FoundLocation } from './definitions'
import type { ChatKind, DefinitionPeek, ExtensionToWebview, LineRange, Occurrence, Reveal, SymbolReference, WebviewToExtension } from './messages'
import type { TargetSpot } from './openTarget'
import type { TopSubject } from './pathPreview'
import type { LinkBase, TopSubject } from './pathPreview'
import type { RepoEntry } from './repoPath'
import { realpath } from 'node:fs/promises'
import { commands, EndOfLine, Position, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
@@ -49,7 +50,7 @@ import { FIND_CONTEXT } from './findCommands'
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
import { occurrencesOf } from './occurrences'
import { languageOf, linesSpot, openingFor, revealOf } from './openTarget'
import { PATH_PREVIEW_SCHEME, topSubjectOf } from './pathPreview'
import { linkBaseOf, PATH_PREVIEW_SCHEME, topSubjectOf } from './pathPreview'
import { ReferenceTargets } from './referenceTargets'
import { findRepoSymbol, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath'
@@ -950,15 +951,11 @@ class Preview {
}
}
/** 预览里这批仓库路径,哪些文件或目录不在;这批不带路径的引用,各按写在哪块找,哪些找不到、哪些是语言自带的名字(页面当普通文字)。 */
/** 预览里这批仓库路径(从哪儿算见 [linkBaseOf]),哪些文件或目录不在;这批不带路径的引用,各按写在哪块找,哪些找不到、哪些是语言自带的名字(页面当普通文字)。 */
private async checkRepoLinks(id: number, paths: string[], references: SymbolReference[]): Promise<void> {
const root = await repoRootOf(this.document.uri.fsPath)
const base = await linkBaseOf(this.document.uri)
const [missing, resolved] = await Promise.all([
Promise.all(paths.map(async (rel) => {
if (root === undefined || await resolveRepoEntry(root, rel) === undefined)
return rel
return undefined
})),
Promise.all(paths.map(async rel => await linkEntry(base, rel) === undefined ? rel : undefined)),
Promise.all(references.map(async reference => ({ reference, target: await this.resolveSymbolLink(reference) }))),
])
this.post({
@@ -993,13 +990,13 @@ class Preview {
/** 这批仓库图片哪些在。在的带回预览里能直接用的地址;不在、不是图片、跑出仓库的没有地址。 */
private async checkRepoImages(id: number, paths: string[]): Promise<void> {
const root = await repoRootOf(this.document.uri.fsPath)
if (root)
await this.allowRepoRoot(root)
const base = await linkBaseOf(this.document.uri)
const images = await Promise.all(paths.map(async (rel) => {
if (root === undefined || !isRepoImagePath(rel))
const place = isRepoImagePath(rel) ? base(rel) : undefined
if (!place)
return { path: rel }
const entry = await resolveRepoEntry(root, rel)
await this.allowRepoRoot(place.root)
const entry = await resolveRepoEntry(place.root, place.rel)
if (!entry || entry.isDirectory)
return { path: rel }
const src = this.panel.webview.asWebviewUri(Uri.file(entry.fsPath)).toString()
@@ -1032,8 +1029,7 @@ class Preview {
}
if (rel === undefined)
return
const root = await repoRootOf(this.document.uri.fsPath)
const entry = root === undefined ? undefined : await resolveRepoEntry(root, rel)
const entry = await linkEntry(await linkBaseOf(this.document.uri), rel)
if (!entry) {
this.markRepoLinkBroken(rel)
return
@@ -1064,8 +1060,7 @@ class Preview {
private async symbolTarget(rel: string | undefined, symbol: string, block: string | undefined): Promise<ReferenceTarget> {
if (rel === undefined)
return this.resolveSymbolLink({ symbol, block })
const root = await repoRootOf(this.document.uri.fsPath)
const entry = root === undefined ? undefined : await resolveRepoEntry(root, rel)
const entry = await linkEntry(await linkBaseOf(this.document.uri), rel)
return entry && !entry.isDirectory ? this.locateRepoSymbol(Uri.file(entry.fsPath), symbol) : undefined
}
@@ -1167,6 +1162,12 @@ class Preview {
}
}
/** 一条仓库链接指到磁盘上的哪个文件或目录,路径按这一页的算法算(见 [linkBaseOf]);算不出来、不在、跑出根的没有。 */
async function linkEntry(base: LinkBase, rel: string): Promise<RepoEntry | undefined> {
const place = base(rel)
return place && resolveRepoEntry(place.root, place.rel)
}
/** 写回重写好的注释:换掉原来那几行(同块编辑),或在块上面插进新写的几行。 */
async function writeCommentEdit(document: TextDocument, edit: CommentEdit): Promise<WriteResult> {
if (edit.kind === 'replace')
+23 -1
View File
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promis
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { findRepoSymbol, isRepoImagePath, pathInsideRoot, resolveRepoEntry } from './repoPath'
import { findRepoSymbol, folderLinkPath, folderLinkPlace, isRepoImagePath, pathInsideRoot, resolveRepoEntry } from './repoPath'
describe('pathInsideRoot', () => {
const root = '/repo'
@@ -96,3 +96,25 @@ describe('resolveRepoEntry', () => {
}
})
})
describe('按工作区文件夹写、读链接路径', () => {
const web = { name: 'web', path: '/ws/web' }
const server = { name: 'server', path: '/srv/app' }
it('只有一个文件夹:路径就相对它,不带文件夹名', () => {
expect(folderLinkPath(['web'], 'web', 'src/app/(main)/page.tsx')).toBe('src/app/(main)/page.tsx')
expect(folderLinkPlace([web], 'src/app/(main)/page.tsx')).toEqual({ root: '/ws/web', rel: 'src/app/(main)/page.tsx' })
})
it('几个文件夹:路径第一段是文件夹名,认回那个文件夹(不管它在磁盘上叫什么)', () => {
expect(folderLinkPath(['web', 'server'], 'server', 'app/routes.py')).toBe('server/app/routes.py')
expect(folderLinkPlace([web, server], 'server/app/routes.py')).toEqual({ root: '/srv/app', rel: 'app/routes.py' })
expect(folderLinkPlace([web, server], 'web/src/a.ts')).toEqual({ root: '/ws/web', rel: 'src/a.ts' })
})
it('第一段不是哪个文件夹的名字、只有文件夹名、没有工作区文件夹:认不出', () => {
expect(folderLinkPlace([web, server], 'other/a.ts')).toBeUndefined()
expect(folderLinkPlace([web, server], 'web')).toBeUndefined()
expect(folderLinkPlace([], 'a.ts')).toBeUndefined()
})
})
+38
View File
@@ -87,6 +87,44 @@ export async function resolveRepoEntry(root: string, rel: string): Promise<RepoE
return { fsPath: fileReal, isDirectory: info.isDirectory() }
}
/** 一个工作区文件夹:名字和它在磁盘上的绝对路径。 */
export interface NamedFolder {
name: string
path: string
}
/** 一条链接路径落在磁盘上的哪个根下(root),根下面是哪段(rel),交给 [resolveRepoEntry] 找。 */
export interface RepoPlace {
root: string
rel: string
}
/** 按工作区文件夹写的链接路径要不要以文件夹的名字开头:几个文件夹时要,不然分不出是哪个;只有一个时路径就相对它。 */
export function namesFolder(folders: readonly unknown[]): boolean {
return folders.length > 1
}
/**
* 一处代码按工作区文件夹写成的链接路径,几个文件夹时前面带上文件夹的名字(见 [namesFolder])。
*
* @param folders 眼下的工作区文件夹的名字
* @param folder 代码所在的工作区文件夹的名字
* @param rel 相对这个文件夹的路径
*/
export function folderLinkPath(folders: readonly string[], folder: string, rel: string): string {
return namesFolder(folders) ? `${folder}/${rel}` : rel
}
/** 把按工作区文件夹写的链接路径([folderLinkPath] 写出来的)认回磁盘上的位置;第一段不是哪个文件夹的名字、没有工作区文件夹时没有。 */
export function folderLinkPlace(folders: readonly NamedFolder[], rel: string): RepoPlace | undefined {
if (!namesFolder(folders)) {
const only = folders.at(0)
return only && { root: only.path, rel }
}
const folder = folders.find(candidate => rel.startsWith(`${candidate.name}/`))
return folder && { root: folder.path, rel: rel.slice(folder.name.length + 1) }
}
/**
* 在符号树里按名字找。`Agent` 找这个类或函数;`Agent.owner_id` 先找 `Agent`,再在它里面找 `owner_id`,点得更多的一层层往里找。
* 同名的有几个时(Rust 的 `struct Order` 和 `impl Order`)挨个试,取第一个里面找得到后面几段的。