From c6de46ad37b726e266cf94d2b1f4299b99a66acf Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 14 Oct 2026 21:25:57 +1100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E3=80=8C=E4=BA=86=E8=A7=A3=E4=B8=80=E5=9D=97?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E3=80=8D=E6=94=B9=E6=88=90=E6=8C=89=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=88=97=E8=B0=83=E7=94=A8=E6=A0=91=EF=BC=9A=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E6=98=AF=E5=87=A0=E6=9D=A1=E8=B7=AF=E5=BE=84=EF=BC=88?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=9A=84=E4=B8=80=E7=A7=8D=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=EF=BC=89=EF=BC=8C=E6=AF=8F=E6=9D=A1=E4=BB=8E?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E4=B8=80=E8=B7=AF=E8=B0=83=E5=88=B0=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=9C=8D=E5=8A=A1=E3=80=81=E6=95=B0=E6=8D=AE=E8=A1=A8?= =?UTF-8?q?=EF=BC=8C=E8=B7=A8=E8=BF=9B=E7=A8=8B=E7=9A=84=E5=86=99=E6=98=8E?= =?UTF-8?q?=E6=80=8E=E4=B9=88=E8=BF=9E=E8=BF=87=E5=8E=BB=EF=BC=88via?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E7=BB=93=E6=9E=84=E7=94=A8=E6=89=81=E5=B9=B3?= =?UTF-8?q?=E7=9A=84=20nodes=20=E5=8A=A0=20parent=20=E8=BF=9E=E6=88=90?= =?UTF-8?q?=E6=A0=91=E3=80=81=E4=B8=8D=E7=94=A8=E9=80=92=E5=BD=92=EF=BC=9B?= =?UTF-8?q?=E4=BE=A7=E6=A0=8F=E8=B7=AF=E5=BE=84=E4=B8=8B=E9=9D=A2=E4=B8=80?= =?UTF-8?q?=E5=B1=82=E5=B1=82=E5=B1=95=E5=BC=80=E8=8A=82=E7=82=B9=EF=BC=8C?= =?UTF-8?q?=E5=9B=9E=E7=AD=94=E9=87=8C=E7=94=A8=E5=B5=8C=E5=A5=97=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E7=94=BB=E6=A0=91=EF=BC=9B=E5=9B=9E=E7=AD=94=E5=92=8C?= =?UTF-8?q?=E6=89=BE=E7=9A=84=E8=BF=87=E7=A8=8B=E9=87=8C=E7=9A=84=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E4=BD=8D=E7=BD=AE=E5=86=99=E6=88=90=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=EF=BC=8C=E5=8A=9F=E8=83=BD=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E9=A1=B5=E4=B8=8A=E7=9A=84=E9=93=BE=E6=8E=A5=E6=8C=89=E6=89=BE?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=94=A8=E7=9A=84=E5=B7=A5=E4=BD=9C=E5=8C=BA?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B9=E6=A3=80=E6=9F=A5=E3=80=81=E6=89=93?= =?UTF-8?q?=E5=BC=80=E3=80=81=E6=82=AC=E5=81=9C=EF=BC=9B=E6=97=A7=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E7=9A=84=E7=BB=93=E6=9E=9C=E5=BD=93=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=EF=BC=8C=E6=A0=8F=E4=B8=8A=E6=96=B9=E6=8F=90=E7=A4=BA=E6=89=93?= =?UTF-8?q?=E5=BC=80=E5=AF=B9=E8=AF=9D=E9=87=8D=E6=96=B0=E6=89=BE=EF=BC=9B?= =?UTF-8?q?=E5=88=A0=E5=8A=9F=E8=83=BD=E5=89=8D=E5=85=88=E5=BC=B9=E6=A8=A1?= =?UTF-8?q?=E6=80=81=E6=A1=86=E7=A1=AE=E8=AE=A4=EF=BC=8C=E8=AF=B4=E6=B8=85?= =?UTF-8?q?=E8=BF=9E=E5=B8=A6=E5=88=A0=E6=8E=89=E8=B0=83=E7=94=A8=E6=A0=91?= =?UTF-8?q?=E5=92=8C=E9=97=AE=E7=AD=94=E8=AE=B0=E5=BD=95=EF=BC=8C=E7=82=B9?= =?UTF-8?q?=E3=80=8C=E5=88=A0=E9=99=A4=E3=80=8D=E6=89=8D=E5=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/editor/pathPreview.ts | 23 ++ .../codeReader/editor/previewEditor.test.ts | 85 ++++++- vscode/src/codeReader/editor/previewEditor.ts | 37 +-- vscode/src/codeReader/editor/repoPath.test.ts | 24 +- vscode/src/codeReader/editor/repoPath.ts | 38 +++ .../src/codeReader/features/featureLocate.ts | 4 +- .../codeReader/features/featureResult.test.ts | 213 +++++++++++----- .../src/codeReader/features/featureResult.ts | 228 ++++++++++++------ .../codeReader/features/featureViews.test.ts | 206 ++++++++++++++++ .../src/codeReader/features/featureViews.ts | 131 ++++++---- .../src/codeReader/features/features.test.ts | 53 ++-- vscode/src/codeReader/features/features.ts | 44 ++-- vscode/src/codeReader/index.ts | 2 +- vscode/src/codeReader/qa/asks.test.ts | 4 +- vscode/src/codeReader/qa/linkRule.test.ts | 38 +++ vscode/src/codeReader/qa/linkRule.ts | 59 ++++- .../src/codeReader/markdown.test.ts | 42 ++++ 17 files changed, 978 insertions(+), 253 deletions(-) create mode 100644 vscode/src/codeReader/features/featureViews.test.ts create mode 100644 vscode/src/codeReader/qa/linkRule.test.ts diff --git a/vscode/src/codeReader/editor/pathPreview.ts b/vscode/src/codeReader/editor/pathPreview.ts index 566952c..ea95a83 100644 --- a/vscode/src/codeReader/editor/pathPreview.ts +++ b/vscode/src/codeReader/editor/pathPreview.ts @@ -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 { + 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: '' }) } diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index 9c69e13..d304ebb 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -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(), + 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(), languageDefinitions: vi.fn(async () => []), })) -/** 仓库在 /workspace,里面只有这几个文件 */ +/** 仓库在 /workspace;磁盘上只有这几个文件(另两个在「了解一块代码」用的工作区文件夹 /ws/web、/srv/app 里) */ vi.mock('./repoPath', async importOriginal => ({ ...await importOriginal(), 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): ExtensionToWebview[] => page.posted().filter(msg => msg.type === 'openFind' || msg.type === 'moveFind') diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 2d8f4c8..560953f 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -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 { - 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 { - 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 { 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 { + const place = base(rel) + return place && resolveRepoEntry(place.root, place.rel) +} + /** 写回重写好的注释:换掉原来那几行(同块编辑),或在块上面插进新写的几行。 */ async function writeCommentEdit(document: TextDocument, edit: CommentEdit): Promise { if (edit.kind === 'replace') diff --git a/vscode/src/codeReader/editor/repoPath.test.ts b/vscode/src/codeReader/editor/repoPath.test.ts index e91c3d3..122cb8f 100644 --- a/vscode/src/codeReader/editor/repoPath.test.ts +++ b/vscode/src/codeReader/editor/repoPath.test.ts @@ -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() + }) +}) diff --git a/vscode/src/codeReader/editor/repoPath.ts b/vscode/src/codeReader/editor/repoPath.ts index 3f430fc..66c140b 100644 --- a/vscode/src/codeReader/editor/repoPath.ts +++ b/vscode/src/codeReader/editor/repoPath.ts @@ -87,6 +87,44 @@ export async function resolveRepoEntry(root: string, rel: string): Promise 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`)挨个试,取第一个里面找得到后面几段的。 diff --git a/vscode/src/codeReader/features/featureLocate.ts b/vscode/src/codeReader/features/featureLocate.ts index f285760..15c77ca 100644 --- a/vscode/src/codeReader/features/featureLocate.ts +++ b/vscode/src/codeReader/features/featureLocate.ts @@ -1,6 +1,6 @@ /** - * 「了解一块代码」的条目按符号名找位置:用语言插件把源码解析成代码块(同预览),在有名字的块里按 `类名.方法名` 一层层找。 - * 每次画栏、点条目时按眼下的代码现找,代码改过也找得到。不碰 VS Code;语言没有插件时用 VS Code 的文档符号(见 featureViews.ts)。 + * 「了解一块代码」调用树上的节点按符号名找位置:用语言插件把源码解析成代码块(同预览),在有名字的块里按 `类名.方法名` 一层层找。 + * 每次画栏、点节点时按眼下的代码现找,代码改过也找得到。不碰 VS Code;语言没有插件时用 VS Code 的文档符号(见 featureViews.ts)。 */ import type { BlockKind, CodeBlock, LanguageAdapter, Node, SourceRange } from '../contract' diff --git a/vscode/src/codeReader/features/featureResult.test.ts b/vscode/src/codeReader/features/featureResult.test.ts index b8c7e08..084dc3d 100644 --- a/vscode/src/codeReader/features/featureResult.test.ts +++ b/vscode/src/codeReader/features/featureResult.test.ts @@ -1,132 +1,229 @@ import type { FeatureResult } from './featureResult' import { describe, expect, it } from 'vitest' -import { featureAnswer, featureFollowUp, featurePrompt, featureSchema, itemLabel, parseFeatureResult } from './featureResult' +import { codeLinkRule } from '../qa/linkRule' +import { childNodes, featureAnswer, featureFollowUp, featurePrompt, featureSchema, itemLabel, MAX_NODES, parseFeatureResult } from './featureResult' const FOLDERS = ['web', 'server'] -const LOGIN: FeatureResult = { - groups: [ +/** 两条路径:首页发出首句从页面一路调到后端的表;已有会话里接着聊经 SSE 连到后端 */ +const CHAT: FeatureResult = { + paths: [ { - name: '登录页面', - items: [ - { folder: 'web', path: 'src/pages/Login.tsx', symbol: 'LoginPage', role: '登录表单,提交账号密码' }, - { folder: 'web', path: '.env', role: '登录接口的地址' }, + name: '首页发出首句', + nodes: [ + { id: '1', folder: 'web', path: 'src/app/(main)/page.tsx', symbol: 'HomePage', role: '首页,用户输入第一句' }, + { id: '2', parent: '1', folder: 'web', path: 'src/api/apps.ts', symbol: 'createApp', role: '发请求建应用' }, + { id: '3', parent: '2', folder: 'server', path: 'app/routes.py', symbol: 'create_app', via: 'POST /apps', role: '建应用的接口' }, + { id: '4', parent: '3', folder: 'server', path: 'app/models.py', symbol: 'App', role: '应用表' }, + { id: '5', parent: '1', folder: 'web', path: '.env', role: '接口地址' }, ], }, { - name: '校验与发令牌', - items: [{ folder: 'server', path: 'app/auth.py', symbol: 'AuthService.login', role: '核对密码、发令牌' }], + name: '已有会话里接着聊', + nodes: [ + { id: 'a', folder: 'web', path: 'src/app/(main)/chat/code/page.tsx', symbol: 'ChatCodePage', role: '会话页' }, + { id: 'b', parent: 'a', folder: 'server', path: 'app/routes.py', symbol: 'stream_events', via: 'GET /sessions/{id}/events(SSE)', role: '推送回复' }, + ], }, ], } describe('交回的结构(--json-schema)', () => { - const schema = JSON.parse(featureSchema(FOLDERS)) as { - properties: { groups: { items: { properties: Record, required: string[] } } } + const raw = featureSchema(FOLDERS) + const schema = JSON.parse(raw) as { + properties: { paths: { items: { properties: { nodes: { type: string, minItems: number, maxItems: number, items: Record } }, required: string[] } } } required: string[] additionalProperties: boolean } - const group = schema.properties.groups.items - const item = (group.properties.items as { items: { properties: Record, required: string[], additionalProperties: boolean } }).items + const callPath = schema.properties.paths.items + const nodes = callPath.properties.nodes + const node = nodes.items as { properties: Record, required: string[], additionalProperties: boolean } - it('若干环节,每个环节有名字和条目;只能交这几项', () => { - expect(schema.required).toEqual(['groups']) + it('若干条路径,每条有名字和一组节点;只能交这几项;节点是扁平的数组,不用递归的 $ref', () => { + expect(schema.required).toEqual(['paths']) expect(schema.additionalProperties).toBe(false) - expect(group.required).toEqual(['name', 'items']) + expect(callPath.required).toEqual(['name', 'nodes']) + expect(nodes).toMatchObject({ type: 'array', minItems: 1, maxItems: MAX_NODES }) + expect(raw).not.toContain('$ref') }) - it('条目:工作区文件夹只能是现有的几个,路径和一句话必填,符号可以不写,不让给行号', () => { - expect(item.properties.folder.enum).toEqual(FOLDERS) - expect(item.required).toEqual(['folder', 'path', 'role']) - expect(Object.keys(item.properties).sort()).toEqual(['folder', 'path', 'role', 'symbol']) - expect(item.additionalProperties).toBe(false) + it('节点:id、工作区文件夹、路径、一句话必填,parent、符号、via 可以不写;工作区文件夹只能是现有的几个,不让给行号', () => { + expect(node.properties.folder.enum).toEqual(FOLDERS) + expect(node.required).toEqual(['id', 'folder', 'path', 'role']) + expect(Object.keys(node.properties).sort()).toEqual(['folder', 'id', 'parent', 'path', 'role', 'symbol', 'via']) + expect(node.additionalProperties).toBe(false) }) }) describe('校验 AI 交回的结果', () => { - it('合格的原样收下,按交回的先后排', () => { - expect(parseFeatureResult(LOGIN, FOLDERS)).toEqual({ result: LOGIN }) + it('合格的调用树原样收下,按交回的先后排', () => { + expect(parseFeatureResult(CHAT, FOLDERS)).toEqual({ result: CHAT }) }) - it('去掉首尾空白;符号是空的当没写', () => { - const output = { groups: [{ name: ' 登录页面 ', items: [{ folder: 'web', path: ' src/a.ts ', symbol: ' ', role: ' 入口 ' }] }] } - expect(parseFeatureResult(output, FOLDERS)).toEqual({ result: { groups: [{ name: '登录页面', items: [{ folder: 'web', path: 'src/a.ts', role: '入口' }] }] } }) + it('parent 可以指向前面任何一个节点,不必紧挨着', () => { + const output = { paths: [{ name: '页面', nodes: [ + { id: 'r', folder: 'web', path: 'a.ts', role: '入口' }, + { id: 'x', parent: 'r', folder: 'web', path: 'b.ts', role: '先调' }, + { id: 'y', parent: 'r', folder: 'web', path: 'c.ts', role: '后调' }, + { id: 'x1', parent: 'x', folder: 'web', path: 'd.ts', role: '先调的里面' }, + ] }] } + expect(parseFeatureResult(output, FOLDERS)).toEqual({ result: output }) }) - it('一个环节都没有也收下:说明没找到相关的代码', () => { - expect(parseFeatureResult({ groups: [] }, FOLDERS)).toEqual({ result: { groups: [] } }) + it('去掉首尾空白;符号、via 是空的当没写', () => { + const output = { paths: [{ name: ' 登录 ', nodes: [ + { id: ' 1 ', folder: 'web', path: ' src/a.ts ', symbol: ' ', via: ' ', role: ' 入口 ' }, + { id: '2', parent: ' 1 ', folder: 'server', path: 'app.py', via: ' POST /login ', role: '接口' }, + ] }] } + expect(parseFeatureResult(output, FOLDERS)).toEqual({ result: { paths: [{ name: '登录', nodes: [ + { id: '1', folder: 'web', path: 'src/a.ts', role: '入口' }, + { id: '2', parent: '1', folder: 'server', path: 'app.py', via: 'POST /login', role: '接口' }, + ] }] } }) }) - it('没交结构化结果、结构不对:说清哪里不对', () => { + it('一条路径都没有也收下:说明没找到相关的代码', () => { + expect(parseFeatureResult({ paths: [] }, FOLDERS)).toEqual({ result: { paths: [] } }) + }) + + it('以前按环节交的旧格式不认', () => { + const old = { groups: [{ name: '登录页面', items: [{ folder: 'web', path: 'src/Login.tsx', symbol: 'LoginPage', role: '登录表单' }] }] } + expect(parseFeatureResult(old, FOLDERS)).toEqual({ error: '交回的结果不对:没有路径列表。' }) + expect(parseFeatureResult(old)).toEqual({ error: '交回的结果不对:没有路径列表。' }) + }) + + it('没交结构化结果、路径没名字、没节点:说清哪里不对', () => { expect(parseFeatureResult(undefined, FOLDERS)).toEqual({ error: 'AI 没按要求的结构交回结果(只回了一段话)。' }) - expect(parseFeatureResult({ groups: 'x' }, FOLDERS)).toEqual({ error: '交回的结果不对:没有环节列表。' }) - expect(parseFeatureResult({ groups: [{ name: '', items: [] }] }, FOLDERS)).toEqual({ error: '交回的结果不对:第 1 个环节没有名字。' }) - expect(parseFeatureResult({ groups: [{ name: '页面', items: [] }] }, FOLDERS)).toEqual({ error: '交回的结果不对:「页面」下面一条都没有。' }) - expect(parseFeatureResult({ groups: [{ name: '页面', items: [{ folder: 'web', path: 'a.ts', role: '' }] }] }, FOLDERS)) - .toEqual({ error: '交回的结果不对:「页面」第 1 条没说它干什么。' }) + expect(parseFeatureResult({ paths: 'x' }, FOLDERS)).toEqual({ error: '交回的结果不对:没有路径列表。' }) + expect(parseFeatureResult({ paths: [{ name: '', nodes: [] }] }, FOLDERS)).toEqual({ error: '交回的结果不对:第 1 条路径没有名字。' }) + expect(parseFeatureResult({ paths: [{ name: '页面', nodes: [] }] }, FOLDERS)).toEqual({ error: '交回的结果不对:「页面」下面一个节点都没有。' }) }) - it('路径必须相对工作区文件夹、不能跑出去;工作区文件夹必须是现有的', () => { - const one = (item: Record): unknown => ({ groups: [{ name: '页面', items: [{ role: '入口', ...item }] }] }) - expect(parseFeatureResult(one({ folder: 'web', path: '/etc/passwd' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 1 条的路径「/etc/passwd」不是相对工作区文件夹的路径。' }) - expect(parseFeatureResult(one({ folder: 'web', path: '../x.ts' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 1 条的路径「../x.ts」不是相对工作区文件夹的路径。' }) - expect(parseFeatureResult(one({ folder: 'other', path: 'a.ts' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 1 条的工作区文件夹「other」不存在。' }) + describe('连不成一棵树的', () => { + const tree = (...nodes: Record[]): unknown => ({ paths: [{ name: '页面', nodes: nodes.map(node => ({ folder: 'web', path: 'a.ts', role: '入口', ...node })) }] }) + + it('没有 id、id 重复', () => { + expect(parseFeatureResult(tree({ id: ' ' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 1 个节点没有 id。' }) + expect(parseFeatureResult(tree({ id: '1' }, { id: '1', parent: '1' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 2 个节点的 id「1」和前面的重复了。' }) + }) + + it('parent 指向不存在的节点、排在后面的节点、自己', () => { + expect(parseFeatureResult(tree({ id: '1' }, { id: '2', parent: '9' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 2 个节点的 parent「9」不是排在它前面的节点。' }) + expect(parseFeatureResult(tree({ id: '1' }, { id: '2', parent: '3' }, { id: '3', parent: '1' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 2 个节点的 parent「3」不是排在它前面的节点。' }) + expect(parseFeatureResult(tree({ id: '1' }, { id: '2', parent: '2' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 2 个节点的 parent「2」不是排在它前面的节点。' }) + }) + + it('一条路径只有一个根:第一个节点写了 parent、后面的节点没写 parent 都不行', () => { + expect(parseFeatureResult(tree({ id: '1', parent: '0' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」的第一个节点是根(用户操作的入口),不写 parent。' }) + expect(parseFeatureResult(tree({ id: '1' }, { id: '2' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 2 个节点没写 parent:一条路径只有一个根,就是第一个节点。' }) + }) + + it('所有路径加起来的节点超过上限', () => { + const chain = (name: string, count: number): unknown => ({ name, nodes: Array.from({ length: count }, (_, index) => ({ id: `${index}`, ...(index > 0 && { parent: `${index - 1}` }), folder: 'web', path: 'a.ts', role: '一层' })) }) + expect(parseFeatureResult({ paths: [chain('甲', MAX_NODES)] }, FOLDERS)).toHaveProperty('result') + expect(parseFeatureResult({ paths: [chain('甲', MAX_NODES - 1), chain('乙', 2)] }, FOLDERS)) + .toEqual({ error: `交回的结果不对:节点太多,所有路径加起来超过了 ${MAX_NODES} 个,只留和这个功能直接相关的。` }) + }) }) - it('读存着的结果不查工作区文件夹:文件夹后来变了,那几条到时候画成找不到', () => { - const output = { groups: [{ name: '页面', items: [{ folder: 'gone', path: 'a.ts', role: '入口' }] }] } + it('节点没说干什么、路径不是相对工作区文件夹的、工作区文件夹不是现有的', () => { + const one = (node: Record): unknown => ({ paths: [{ name: '页面', nodes: [{ id: '1', role: '入口', ...node }] }] }) + expect(parseFeatureResult(one({ folder: 'web', path: 'a.ts', role: '' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 1 个节点没说它干什么。' }) + expect(parseFeatureResult(one({ folder: 'web', path: '/etc/passwd' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 1 个节点的路径「/etc/passwd」不是相对工作区文件夹的路径。' }) + expect(parseFeatureResult(one({ folder: 'web', path: '../x.ts' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 1 个节点的路径「../x.ts」不是相对工作区文件夹的路径。' }) + expect(parseFeatureResult(one({ folder: 'other', path: 'a.ts' }), FOLDERS)).toEqual({ error: '交回的结果不对:「页面」第 1 个节点的工作区文件夹「other」不存在。' }) + }) + + it('读存着的结果不查工作区文件夹:文件夹后来变了,那几个节点到时候画成找不到', () => { + const output = { paths: [{ name: '页面', nodes: [{ id: '1', folder: 'gone', path: 'a.ts', role: '入口' }] }] } expect(parseFeatureResult(output)).toEqual({ result: output }) }) }) +describe('调用树上一个节点调用的节点', () => { + it('不给父节点是根;给了是它直接调用的,按调用的先后排', () => { + const [home] = CHAT.paths + expect(childNodes(home).map(node => node.id)).toEqual(['1']) + expect(childNodes(home, home.nodes[0]).map(node => node.id)).toEqual(['2', '5']) + expect(childNodes(home, home.nodes[3])).toEqual([]) + }) +}) + describe('发给 AI 的话', () => { const folders = [{ name: 'web', path: '/ws/web' }, { name: 'server', path: '/ws/server' }] - it('开新会话:要找的功能、在哪些工作区文件夹里找、交回什么、只读不改、不给行号、只列直接相关的、中文', () => { + it('开新会话:要找的功能、在哪些工作区文件夹里找、只读不改、不给行号、中文', () => { const prompt = featurePrompt('用户登录', folders) expect(prompt).toContain('「用户登录」') expect(prompt).toContain('- web:/ws/web') expect(prompt).toContain('- server:/ws/server') + expect(prompt).toContain('不要改任何东西') expect(prompt).toMatch(/类名\.方法名/) expect(prompt).toContain('不要写行号') - expect(prompt).toContain('只列和这个功能直接相关的') expect(prompt).toContain('中文') expect(prompt).not.toContain('补充') }) + it('要的是路径和调用树:按操作的先后分路径、根是入口、像调用栈往下写、跨进程写 via、parent 指父节点、只写直接相关的', () => { + const prompt = featurePrompt('用户登录', folders) + expect(prompt).toContain('把前后端串成调用树') + expect(prompt).toContain('按用户实际操作的先后排') + expect(prompt).toContain('像调用栈一样从入口往下写到关键的叶子') + expect(prompt).toContain('根是用户操作的入口(页面组件、按钮的处理函数、CLI 命令、定时任务等)') + expect(prompt).toContain('前端发请求的节点下面接后端处理这个请求的路由函数') + expect(prompt).toContain('跨进程的那一层在 via 里写明是怎么连过去的') + expect(prompt).toContain('同一进程里的普通函数调用不写 via') + expect(prompt).toContain('第一个节点是根,不写 parent') + expect(prompt).toContain('同一个函数出现在几条路径里,就在每条路径里各写一次') + expect(prompt).toContain('通用工具、框架代码、日志这类跳过') + expect(prompt).toContain(`不超过 ${MAX_NODES} 个节点`) + }) + + it('过程里提到的代码位置写成链接:几个工作区文件夹时路径以文件夹名开头,只有一个时相对它', () => { + expect(featurePrompt('用户登录', folders)).toContain(codeLinkRule(folders)) + expect(codeLinkRule(folders)).toContain('路径的第一段是工作区文件夹的名字(web、server)') + const [web] = folders + expect(featurePrompt('用户登录', [web])).toContain(codeLinkRule([web])) + expect(codeLinkRule([web])).toContain('路径从工作区文件夹 /ws/web 算起') + }) + it('会话找不到了重新开时,用户接着说的话跟在后面', () => { expect(featurePrompt('用户登录', folders, '还漏了找回密码')).toMatch(/「用户登录」[\s\S]*\n\n用户补充:还漏了找回密码$/) }) - it('接着上次的会话说:用户的话,再要完整的新结果', () => { - expect(featureFollowUp('XX 不相关')).toBe('XX 不相关\n\n按这个改,交回改完之后完整的结果(全部环节和条目,不是只交改动的部分)。') + it('接着上次的会话说:用户的话,再要完整的路径和调用树,代码位置照样写成链接', () => { + expect(featureFollowUp('XX 不相关', folders)).toBe(`XX 不相关\n\n按这个改,交回改完之后完整的结果(全部路径和每条路径完整的调用树,不是只交改动的部分)。\n\n${codeLinkRule(folders)}`) }) }) describe('对话里显示的回答', () => { - it('按环节列出条目:符号、文件、一句话', () => { - expect(featureAnswer(LOGIN, false)).toBe([ - '**1. 登录页面**', + it('每条路径一个加粗标题,下面用嵌套列表画调用树:节点是仓库链接加一句话,跨进程的前面写 via;几个工作区文件夹时路径带文件夹名', () => { + expect(featureAnswer(CHAT, FOLDERS)).toBe([ + '**1. 首页发出首句**', '', - '- `LoginPage`(`src/pages/Login.tsx`):登录表单,提交账号密码', - '- `.env`:登录接口的地址', + '- [HomePage](web/src/app/(main)/page.tsx#HomePage):首页,用户输入第一句', + ' - [createApp](web/src/api/apps.ts#createApp):发请求建应用', + ' - `POST /apps` → [create\\_app](server/app/routes.py#create_app):建应用的接口', + ' - [App](server/app/models.py#App):应用表', + ' - [\\.env](web/.env):接口地址', '', - '**2. 校验与发令牌**', + '**2. 已有会话里接着聊**', '', - '- `AuthService.login`(`app/auth.py`):核对密码、发令牌', + '- [ChatCodePage](web/src/app/(main)/chat/code/page.tsx#ChatCodePage):会话页', + ' - `GET /sessions/{id}/events(SSE)` → [stream\\_events](server/app/routes.py#stream_events):推送回复', ].join('\n')) }) - it('几个工作区文件夹时路径前带上文件夹名;一个环节都没有时说没找到', () => { - expect(featureAnswer({ groups: [LOGIN.groups[1]] }, true)).toBe('**1. 校验与发令牌**\n\n- `AuthService.login`(`server/app/auth.py`):核对密码、发令牌') - expect(featureAnswer({ groups: [] }, false)).toBe('没找到和这个功能直接相关的代码。') + it('只有一个工作区文件夹时路径相对它;一条路径都没有时说没找到', () => { + const one: FeatureResult = { paths: [{ name: '会话', nodes: [{ id: '1', folder: 'web', path: 'src/app/(main)/chat/code/page.tsx', symbol: 'ChatCodePage', role: '会话页' }] }] } + expect(featureAnswer(one, ['web'])).toBe('**1. 会话**\n\n- [ChatCodePage](src/app/(main)/chat/code/page.tsx#ChatCodePage):会话页') + expect(featureAnswer({ paths: [] }, FOLDERS)).toBe('没找到和这个功能直接相关的代码。') }) }) -describe('栏里条目的名字', () => { +describe('栏里、对话里节点的名字', () => { it('有符号写符号,只有文件的写文件名', () => { - expect(itemLabel(LOGIN.groups[0].items[0])).toBe('LoginPage') - expect(itemLabel(LOGIN.groups[0].items[1])).toBe('.env') + expect(itemLabel(CHAT.paths[0].nodes[0])).toBe('HomePage') + expect(itemLabel(CHAT.paths[0].nodes[4])).toBe('.env') expect(itemLabel({ folder: 'web', path: 'config/app.yaml', role: '配置' })).toBe('app.yaml') }) }) diff --git a/vscode/src/codeReader/features/featureResult.ts b/vscode/src/codeReader/features/featureResult.ts index 3183e06..b00867f 100644 --- a/vscode/src/codeReader/features/featureResult.ts +++ b/vscode/src/codeReader/features/featureResult.ts @@ -1,14 +1,16 @@ /** - * 「了解一块代码」:AI 在整个工作区里找一个功能的代码,按 JSON Schema 交回若干「环节」,每个环节下面是文件 + 符号 + 一句话。 + * 「了解一块代码」:AI 在整个工作区里找一个功能的代码,按 JSON Schema 交回若干条路径,每条路径是从用户操作的入口一路调下去的调用树。 * 这里是交回的结构、怎么校验、发给 AI 的话、对话里怎么显示。不碰 VS Code。 */ +import type { NamedFolder } from '../editor/repoPath' import * as path from 'node:path' -import { isSafeRepoPath } from '../editor/repoPath' +import { folderLinkPath, isSafeRepoPath } from '../editor/repoPath' +import { codeLink, codeLinkRule } from '../qa/linkRule' /** - * 一条:在哪个工作区文件夹的哪个文件(相对这个文件夹),哪个函数 / 方法 / 类 / 组件(方法写成 `类名.方法名`,配置文件这类没有函数的不写), - * 和一句话说它在这个功能里干什么。不带行号:每次按眼下的代码现找(见 featureLocate.ts),代码改过也找得到。 + * 一处代码:在哪个工作区文件夹的哪个文件(相对这个文件夹),哪个函数 / 方法 / 类 / 组件(方法写成 `类名.方法名`,配置文件这类没有函数的不写), + * 和一句话说它在这里干什么。不带行号:每次按眼下的代码现找(见 [locateItem](vscode/src/codeReader/features/featureViews.ts#locateItem)),代码改过也找得到。 */ export interface FeatureItem { folder: string @@ -17,51 +19,74 @@ export interface FeatureItem { role: string } -/** 一个环节:按功能起的名字(「登录页面」「校验与发令牌」)和下面的条目。 */ -export interface FeatureGroup { +/** + * 调用树上的一个节点:一处代码,加上它在这条路径里的编号(`id`)、调用它的节点(`parent`,根没有), + * 和跨进程时怎么从父节点连过来的(`via`,如 `POST /apps`、`WebSocket`;同一进程里的普通调用没有)。 + */ +export interface CallNode extends FeatureItem { + id: string + parent?: string + via?: string +} + +/** 一条路径:用户的一种操作(`name` 说它干什么),和从入口一路调下去的调用树(`nodes`:第一个是根,每个节点都排在它的父节点后面)。 */ +export interface CallPath { name: string - items: FeatureItem[] + nodes: CallNode[] } -/** AI 交回的一次结果:各环节按功能流程的先后排。一个环节都没有是没找到相关的代码。 */ +/** AI 交回的一次结果:各条路径按用户实际操作的先后排。一条都没有是没找到相关的代码。 */ export interface FeatureResult { - groups: FeatureGroup[] + paths: CallPath[] } -/** 给 `--json-schema` 的结构:folders 是工作区文件夹的名字,条目的 folder 只能是其中之一。 */ +/** 所有路径加起来最多几个节点:再多,栏里、对话里都看不过来。 */ +export const MAX_NODES = 150 + +/** + * 给 `--json-schema` 的结构:几条路径,每条一个 `nodes` 数组,节点用 `id`、`parent` 连成树。 + * 不用递归的 `$ref`:Claude Code 用 StructuredOutput 工具校验交回的结果,递归的结构不一定认,扁平的一定认。 + * + * @param folders 工作区文件夹的名字,节点的 `folder` 只能是其中之一 + */ export function featureSchema(folders: readonly string[]): string { return JSON.stringify({ type: 'object', properties: { - groups: { + paths: { type: 'array', - description: '这个功能的各个环节,按功能流程的先后排', + description: '这个功能的各条路径,用户的一种操作一条,按用户实际操作的先后排', items: { type: 'object', properties: { - name: { type: 'string', description: '按功能起的环节名,如「登录页面」「登录接口」「校验与发令牌」「用户表」' }, - items: { + name: { type: 'string', description: '这条路径干什么,如「首页发出首句」「已有会话里接着聊」「打开历史会话回放」' }, + nodes: { type: 'array', minItems: 1, + maxItems: MAX_NODES, + description: '这条路径的调用树,像调用栈一样从入口往下写:第一个是根(用户操作的入口),别的节点都写在它的父节点后面,同一个父节点下的按调用的先后排', items: { type: 'object', properties: { + id: { type: 'string', description: '节点在这条路径里的编号,不能重复,如「1」「2」' }, + parent: { type: 'string', description: '调用它的节点的 id,必须是写在它前面的节点;只有第一个节点(根)不写' }, folder: { type: 'string', enum: [...new Set(folders)], description: '文件所在的工作区文件夹的名字' }, path: { type: 'string', description: '相对这个工作区文件夹的路径,用 / 分隔' }, symbol: { type: 'string', description: '函数、方法、类或组件的名字,方法写成「类名.方法名」,要能在文件里唯一找到;配置文件这类没有函数的不写' }, - role: { type: 'string', description: '一句中文:它在这个功能里干什么' }, + via: { type: 'string', description: '跨进程时怎么从父节点连过来,如「POST /apps」「GET /sessions/{id}/events(SSE)」「WebSocket」「路由跳转 /chat/code」「消息队列」;同一进程里的普通函数调用不写' }, + role: { type: 'string', description: '一句中文:它在这条路径里干什么' }, }, - required: ['folder', 'path', 'role'], + required: ['id', 'folder', 'path', 'role'], additionalProperties: false, }, }, }, - required: ['name', 'items'], + required: ['name', 'nodes'], additionalProperties: false, }, }, }, - required: ['groups'], + required: ['paths'], additionalProperties: false, }) } @@ -76,91 +101,144 @@ function text(value: unknown): string | undefined { return trimmed === '' ? undefined : trimmed } +/** 交回的结果哪里不对,给人看的话。 */ +function wrong(detail: string): { error: string } { + return { error: `交回的结果不对:${detail}` } +} + /** - * 校验交回的结果,合格的去掉首尾空白收下,不合格的说清哪里不对(给人看的话)。 - * 给了 folders 时(AI 刚交回的)条目的工作区文件夹必须是其中之一;读存着的不给:工作区文件夹后来变了,那几条到时候画成找不到。 + * 校验交回的结果,合格的去掉首尾空白收下,不合格的说清哪里不对(给人看的话)。只认路径和调用树,以前按环节交的结果也不合格。 + * 给了 folders 时(AI 刚交回的)节点的工作区文件夹必须是其中之一;读存着的不给:工作区文件夹后来变了,那几个节点到时候画成找不到。 */ export function parseFeatureResult(output: unknown, folders?: readonly string[]): { result: FeatureResult } | { error: string } { if (output === undefined) return { error: 'AI 没按要求的结构交回结果(只回了一段话)。' } - const wrong = (detail: string): { error: string } => ({ error: `交回的结果不对:${detail}` }) - if (!isRecord(output) || !Array.isArray(output.groups)) - return wrong('没有环节列表。') - const groups: FeatureGroup[] = [] - for (const [index, group] of output.groups.entries()) { - const name = isRecord(group) ? text(group.name) : undefined + if (!isRecord(output) || !Array.isArray(output.paths)) + return wrong('没有路径列表。') + const paths: CallPath[] = [] + let total = 0 + for (const [index, raw] of output.paths.entries()) { + const name = isRecord(raw) ? text(raw.name) : undefined if (name === undefined) - return wrong(`第 ${index + 1} 个环节没有名字。`) - const items = isRecord(group) && Array.isArray(group.items) ? group.items : [] - if (items.length === 0) - return wrong(`「${name}」下面一条都没有。`) - const parsed: FeatureItem[] = [] - for (const [at, raw] of items.entries()) { - const where = `「${name}」第 ${at + 1} 条` - const item = isRecord(raw) ? raw : {} - const folder = text(item.folder) - const file = text(item.path) - const role = text(item.role) - const symbol = text(item.symbol) - if (folder === undefined || (folders && !folders.includes(folder))) - return wrong(`${where}的工作区文件夹「${folder ?? ''}」不存在。`) - if (file === undefined || !isSafeRepoPath(file)) - return wrong(`${where}的路径「${file ?? ''}」不是相对工作区文件夹的路径。`) - if (role === undefined) - return wrong(`${where}没说它干什么。`) - parsed.push({ folder, path: file, ...(symbol !== undefined && { symbol }), role }) - } - groups.push({ name, items: parsed }) + return wrong(`第 ${index + 1} 条路径没有名字。`) + const nodes = isRecord(raw) && Array.isArray(raw.nodes) ? raw.nodes : [] + if (nodes.length === 0) + return wrong(`「${name}」下面一个节点都没有。`) + total += nodes.length + if (total > MAX_NODES) + return wrong(`节点太多,所有路径加起来超过了 ${MAX_NODES} 个,只留和这个功能直接相关的。`) + const tree = parseTree(name, nodes, folders) + if ('error' in tree) + return tree + paths.push({ name, nodes: tree.nodes }) } - return { result: { groups } } -} - -/** 发给 AI 的工作区文件夹:名字和磁盘上的路径。 */ -export interface PromptFolder { - name: string - path: string + return { result: { paths } } } /** - * 开新会话时发给 AI 的话:要找哪个功能、在哪些工作区文件夹里找、交回什么样的结果。 + * 校验名叫 `name` 的那条路径的调用树:`id` 不重复;只有第一个节点是根、不写 `parent`,别的节点的 `parent` 都指向排在它前面的节点, + * 所以一条路径正好是一棵树,没有环。一条路径一个根:一条路径就是用户的一种操作,入口只有一个,另一个入口是另一条路径。 + */ +function parseTree(name: string, nodes: readonly unknown[], folders?: readonly string[]): { nodes: CallNode[] } | { error: string } { + const parsed: CallNode[] = [] + const ids = new Set() + for (const [at, raw] of nodes.entries()) { + const where = `「${name}」第 ${at + 1} 个节点` + const node = isRecord(raw) ? raw : {} + const id = text(node.id) + const parent = text(node.parent) + if (id === undefined) + return wrong(`${where}没有 id。`) + if (ids.has(id)) + return wrong(`${where}的 id「${id}」和前面的重复了。`) + if (at === 0 && parent !== undefined) + return wrong(`「${name}」的第一个节点是根(用户操作的入口),不写 parent。`) + if (at > 0 && parent === undefined) + return wrong(`${where}没写 parent:一条路径只有一个根,就是第一个节点。`) + if (parent !== undefined && !ids.has(parent)) + return wrong(`${where}的 parent「${parent}」不是排在它前面的节点。`) + const item = parseItem(where, node, folders) + if ('error' in item) + return item + const via = text(node.via) + ids.add(id) + parsed.push({ id, ...(parent !== undefined && { parent }), ...item, ...(via !== undefined && { via }) }) + } + return { nodes: parsed } +} + +/** 校验 `where` 那个节点在哪、干什么([FeatureItem] 那几项)。 */ +function parseItem(where: string, node: Record, folders?: readonly string[]): FeatureItem | { error: string } { + const folder = text(node.folder) + const file = text(node.path) + const role = text(node.role) + const symbol = text(node.symbol) + if (folder === undefined || (folders && !folders.includes(folder))) + return wrong(`${where}的工作区文件夹「${folder ?? ''}」不存在。`) + if (file === undefined || !isSafeRepoPath(file)) + return wrong(`${where}的路径「${file ?? ''}」不是相对工作区文件夹的路径。`) + if (role === undefined) + return wrong(`${where}没说它干什么。`) + return { folder, path: file, ...(symbol !== undefined && { symbol }), role } +} + +/** + * 开新会话时发给 AI 的话:要找哪个功能、在哪些工作区文件夹里找、交回什么样的路径和调用树,过程里提到的代码位置写成链接(见 [codeLinkRule])。 * said 是会话找不到了重新开时用户接着说的话,跟在最后。 */ -export function featurePrompt(question: string, folders: readonly PromptFolder[], said?: string): string { +export function featurePrompt(question: string, folders: readonly NamedFolder[], said?: string): string { const lines = [ `在下面这些工作区文件夹里找出「${question}」这个功能的代码:`, ...folders.map(folder => `- ${folder.name}:${folder.path}`), '', - '只读、搜文件,不要改任何东西。找完按给定的结构交回:', - '- 把这个功能分成几个环节,按功能流程的先后排,每个环节起一个按功能叫的名字(如「登录页面」「登录接口」「校验与发令牌」「用户表」)。', - '- 每个环节下面列出相关的代码:folder 是文件所在的工作区文件夹的名字,path 是相对这个文件夹的路径;' - + 'symbol 是函数、方法、类或组件的名字,方法写成「类名.方法名」这样能在文件里唯一找到的写法;配置文件这类没有函数的条目不写 symbol。', - '- role 用一句中文说它在这个功能里干什么。', - '- 不要写行号。只列和这个功能直接相关的代码,顺带用到的通用工具、框架代码不要列。', + '只读、搜文件,不要改任何东西。找完把前后端串成调用树,按给定的结构交回:', + '- 这个功能里用户有几种操作就分几条路径,按用户实际操作的先后排;每条路径起一个说它干什么的名字(如「首页发出首句」「已有会话里接着聊」「打开历史会话回放」)。', + '- 每条路径是一棵调用树,像调用栈一样从入口往下写到关键的叶子:根是用户操作的入口(页面组件、按钮的处理函数、CLI 命令、定时任务等),' + + '每个节点下面是它调用的、和这个功能直接相关的代码,按调用的先后排。', + '- 前端发请求的节点下面接后端处理这个请求的路由函数,再往下到服务、模型和数据表、外部调用。' + + '跨进程的那一层在 via 里写明是怎么连过去的,如「POST /apps」「GET /sessions/{id}/events(SSE)」「WebSocket」「路由跳转 /chat/code」「消息队列」;同一进程里的普通函数调用不写 via。', + '- nodes 里第一个节点是根,不写 parent;别的节点都写在它的父节点后面,parent 写父节点的 id;id 在一条路径里不能重复。同一个函数出现在几条路径里,就在每条路径里各写一次。', + `- 只写和这个功能直接相关的节点,通用工具、框架代码、日志这类跳过;所有路径加起来不超过 ${MAX_NODES} 个节点。`, + '- folder 是文件所在的工作区文件夹的名字,path 是相对这个文件夹的路径;' + + 'symbol 是函数、方法、类或组件的名字,方法写成「类名.方法名」这样能在文件里唯一找到的写法;配置文件这类没有函数的节点不写 symbol。', + '- role 用一句中文说它在这条路径里干什么。不要写行号。', + '', + codeLinkRule(folders), ] const prompt = lines.join('\n') return said === undefined ? prompt : `${prompt}\n\n用户补充:${said}` } -/** 接着上次的会话说时发的:用户的话,再要改完之后完整的结果。 */ -export function featureFollowUp(said: string): string { - return `${said}\n\n按这个改,交回改完之后完整的结果(全部环节和条目,不是只交改动的部分)。` +/** 接着上次的会话说时发的:用户的话,再要改完之后完整的路径和调用树,代码位置照样写成链接(见 [codeLinkRule])。 */ +export function featureFollowUp(said: string, folders: readonly NamedFolder[]): string { + return `${said}\n\n按这个改,交回改完之后完整的结果(全部路径和每条路径完整的调用树,不是只交改动的部分)。\n\n${codeLinkRule(folders)}` } -/** 栏里、对话里一条的名字:有符号写符号,只有文件的写文件名。 */ +/** 栏里、对话里一个节点的名字:有符号写符号,只有文件的写文件名。 */ export function itemLabel(item: FeatureItem): string { return item.symbol ?? path.posix.basename(item.path) } +/** `callPath` 里 `parent` 直接调用的节点,按调用的先后排;不给 `parent` 时是根。 */ +export function childNodes(callPath: CallPath, parent?: CallNode): CallNode[] { + return callPath.nodes.filter(node => node.parent === parent?.id) +} + /** - * 对话里显示的这一轮回答(Markdown):按环节列出各条(符号、文件、一句话),和栏里的一样。 - * showFolder:几个工作区文件夹时路径前带上文件夹名。 + * 对话里显示的这一轮回答(Markdown),和栏里的一样:每条路径一个加粗的标题,下面用嵌套列表画调用树。 + * 每个节点是点了能跳过去的仓库链接(见 [codeLink])加一句话,跨进程连过来的前面写上怎么连的。 + * + * @param result AI 交回的结果 + * @param folders 工作区文件夹的名字,几个时链接路径前带上文件夹名(见 [folderLinkPath]) */ -export function featureAnswer(result: FeatureResult, showFolder: boolean): string { - if (result.groups.length === 0) +export function featureAnswer(result: FeatureResult, folders: readonly string[]): string { + if (result.paths.length === 0) return '没找到和这个功能直接相关的代码。' - const where = (item: FeatureItem): string => showFolder ? `${item.folder}/${item.path}` : item.path - const line = (item: FeatureItem): string => item.symbol === undefined - ? `- \`${where(item)}\`:${item.role}` - : `- \`${item.symbol}\`(\`${where(item)}\`):${item.role}` - return result.groups.map((group, index) => [`**${index + 1}. ${group.name}**`, '', ...group.items.map(line)].join('\n')).join('\n\n') + const line = (node: CallNode, depth: number): string => { + const link = codeLink(itemLabel(node), folderLinkPath(folders, node.folder, node.path), node.symbol) + return `${' '.repeat(depth)}- ${node.via === undefined ? '' : `\`${node.via}\` → `}${link}:${node.role}` + } + const tree = (callPath: CallPath, parent: CallNode | undefined, depth: number): string[] => + childNodes(callPath, parent).flatMap(node => [line(node, depth), ...tree(callPath, node, depth + 1)]) + return result.paths.map((callPath, index) => [`**${index + 1}. ${callPath.name}**`, '', ...tree(callPath, undefined, 0)].join('\n')).join('\n\n') } diff --git a/vscode/src/codeReader/features/featureViews.test.ts b/vscode/src/codeReader/features/featureViews.test.ts new file mode 100644 index 0000000..7f717c7 --- /dev/null +++ b/vscode/src/codeReader/features/featureViews.test.ts @@ -0,0 +1,206 @@ +import type { WorkspaceFolder } from 'vscode' +import type { FeatureItem, FeatureResult } from './featureResult' +import type { FoundItem } from './featureViews' +import { describe, expect, it, vi } from 'vitest' +import { Uri, window } from 'vscode' +import { FeatureTree, FeatureViews, OPEN_FEATURE_ITEM } from './featureViews' + +vi.mock('vscode', () => { + class FakeUri { + constructor(readonly scheme: string, readonly path: string) {} + static from({ scheme, path }: { scheme: string, path: string }): FakeUri { + return new FakeUri(scheme, path) + } + + static file(path: string): FakeUri { + return new FakeUri('file', path) + } + + static joinPath(base: FakeUri, ...segments: string[]): FakeUri { + return new FakeUri(base.scheme, [base.path, ...segments].join('/')) + } + + toString(): string { + return `${this.scheme}://${this.path}` + } + } + class ThemeIcon { + static File = new ThemeIcon('file') + constructor(readonly id: string, readonly color?: unknown) {} + } + const listen = (): { dispose: () => void } => ({ dispose: () => {} }) + return { + window: { + createTreeView: () => ({ dispose: () => {} }), + registerFileDecorationProvider: listen, + showWarningMessage: vi.fn(), + tabGroups: { all: [], close: vi.fn() }, + }, + TabInputCustom: class {}, + FileDecoration: class {}, + EventEmitter: class { + event = () => ({ dispose: () => {} }) + fire(): void {} + dispose(): void {} + }, + TreeItem: class { + constructor(readonly label: string, readonly collapsibleState: number) {} + }, + TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, + MarkdownString: class { + constructor(readonly value: string) {} + }, + ThemeIcon, + ThemeColor: class { + constructor(readonly id: string) {} + }, + Uri: FakeUri, + workspace: { + workspaceFolders: [{ name: 'web', uri: FakeUri.file('/ws/web') }, { name: 'server', uri: FakeUri.file('/srv/app') }], + createFileSystemWatcher: () => ({ onDidChange: listen, onDidCreate: listen, onDidDelete: listen, dispose: () => {} }), + onDidChangeWorkspaceFolders: listen, + }, + } +}) + +const RESULT: FeatureResult = { + paths: [ + { + name: '首页发出首句', + nodes: [ + { id: '1', folder: 'web', path: 'src/app/(main)/page.tsx', symbol: 'HomePage', role: '首页,用户输入第一句' }, + { id: '2', parent: '1', folder: 'web', path: 'src/api/apps.ts', symbol: 'createApp', role: '发请求建应用' }, + { id: '3', parent: '2', folder: 'server', path: 'app/routes.py', symbol: 'create_app', via: 'POST /apps', role: '建应用的接口' }, + { id: '4', parent: '3', folder: 'server', path: 'app/models.py', symbol: 'App', role: '应用表' }, + { id: '5', parent: '1', folder: 'web', path: '.env', role: '接口地址' }, + ], + }, + { + name: '已有会话里接着聊', + nodes: [{ id: 'a', folder: 'web', path: 'src/app/(main)/chat/code/page.tsx', symbol: 'ChatCodePage', role: '会话页' }], + }, + ], +} + +/** 按眼下的代码找:`App` 这个类已经删掉了,别的都在 */ +async function locate(item: FeatureItem): Promise { + if (item.symbol === 'App') + return undefined + const folder = { name: item.folder, uri: Uri.file(`/${item.folder}`) } as unknown as WorkspaceFolder + return { uri: Uri.file(`/${item.folder}/${item.path}`), folder, ...(item.symbol !== undefined && { kind: 'function' }) } +} + +describe('功能栏里的树', () => { + it('最上面是各条路径,展开是根节点;节点一层层展开到它调用的节点,按调用的先后排;没调用别的节点的不能展开', async () => { + const tree = new FeatureTree(3, RESULT, locate) + const paths = await tree.getChildren() + expect(paths.map(row => tree.getTreeItem(row))).toMatchObject([ + { label: '首页发出首句', collapsibleState: 2 }, + { label: '已有会话里接着聊', collapsibleState: 2 }, + ]) + + const [home] = await tree.getChildren(paths[0]) + expect(tree.getTreeItem(home)).toMatchObject({ label: 'HomePage', description: '首页,用户输入第一句', collapsibleState: 2 }) + const calls = await tree.getChildren(home) + expect(calls.map(row => tree.getTreeItem(row).label)).toEqual(['createApp', '.env']) + expect(tree.getTreeItem(calls[1]).collapsibleState).toBe(0) + + const [route] = await tree.getChildren(calls[0]) + expect(tree.getTreeItem(route)).toMatchObject({ label: 'create_app', description: 'POST /apps · 建应用的接口', collapsibleState: 2 }) + + const [chat] = await tree.getChildren(paths[1]) + expect(tree.getTreeItem(chat)).toMatchObject({ label: 'ChatCodePage', collapsibleState: 0 }) + expect(await tree.getChildren(chat)).toEqual([]) + }) + + it('找不到的节点照样在树上,画成找不到、不挂菜单', async () => { + const tree = new FeatureTree(3, RESULT, locate) + const [home] = await tree.getChildren((await tree.getChildren())[0]) + const [createApp] = await tree.getChildren(home) + const [route] = await tree.getChildren(createApp) + const [model] = await tree.getChildren(route) + expect(model).toMatchObject({ missing: true, node: { symbol: 'App' } }) + expect(tree.getTreeItem(model)).toMatchObject({ label: 'App', contextValue: 'feature missing', collapsibleState: 0 }) + expect(tree.getTreeItem(model).tooltip).toEqual({ value: '找不到了 \n**app\\/models\\.py** \n应用表' }) + }) + + it('点节点用打开节点的命令,参数是这一行;悬停是文件路径和那句话', async () => { + const tree = new FeatureTree(3, RESULT, locate) + const [home] = await tree.getChildren((await tree.getChildren())[0]) + const item = tree.getTreeItem(home) + expect(item.command).toEqual({ command: OPEN_FEATURE_ITEM, title: '打开', arguments: [home] }) + expect(item.tooltip).toEqual({ value: '**src\\/app\\/\\(main\\)\\/page\\.tsx** \n首页,用户输入第一句' }) + }) + + it('认得出哪个文件里有节点:文件改了时只重找这些栏', () => { + const tree = new FeatureTree(3, RESULT, locate) + expect(tree.covers(Uri.file('/srv/app/app/routes.py'))).toBe(true) + expect(tree.covers(Uri.file('/srv/app/app/other.py'))).toBe(false) + }) +}) + +describe('删除一个功能前先确认', () => { + /** 第 3 栏存着一个功能,栏标题改叫「聊天」 */ + function opened() { + const stored = [{ id: 'f1', question: '用户是如何跟 code agent 聊天的', name: '聊天', slot: 3, result: RESULT }] + const state = { get: () => stored, update: vi.fn(async () => {}) } + const pool = { occupy: vi.fn(async () => {}), release: vi.fn(async () => {}) } + const qa = { onDidAnswer: () => ({ dispose: () => {} }), deleteTop: vi.fn(async () => {}) } + vi.mocked(window.showWarningMessage).mockReset() + const features = new FeatureViews(...[state, pool, qa, locate] as unknown as ConstructorParameters) + return { features, state, pool, qa } + } + + /** 什么都没删:栏还在,记录、栏位、问答记录都没动 */ + function untouched({ features, state, pool, qa }: ReturnType): void { + expect(features.chatPage(3)).toBeDefined() + expect(state.update).not.toHaveBeenCalled() + expect(pool.release).not.toHaveBeenCalled() + expect(qa.deleteTop).not.toHaveBeenCalled() + } + + it('模态框说清删的是哪个功能、连带删掉什么;点了「删除」才删栏、空出栏位、删问答记录', async () => { + const page = opened() + vi.mocked(window.showWarningMessage).mockResolvedValueOnce('删除' as never) + try { + await page.features.remove(3) + expect(window.showWarningMessage).toHaveBeenCalledWith( + '删掉「聊天」?找到的调用树和问答记录(连同带的图片)会一起删掉,正在找的也会停下,删了找不回来。', + { modal: true }, + '删除', + ) + expect(page.state.update).toHaveBeenCalledWith('superpowers.codeReader.features', undefined) + expect(page.pool.release).toHaveBeenCalledWith(3) + expect(page.qa.deleteTop).toHaveBeenCalledTimes(1) + expect(page.features.chatPage(3)).toBeUndefined() + } + finally { + page.features.dispose() + } + }) + + it('点「取消」(VS Code 模态框自带的按钮,答回来是 undefined):什么都不删', async () => { + const page = opened() + vi.mocked(window.showWarningMessage).mockResolvedValueOnce(undefined) + try { + await page.features.remove(3) + expect(window.showWarningMessage).toHaveBeenCalledTimes(1) + untouched(page) + } + finally { + page.features.dispose() + } + }) + + it('关掉对话框(按 Esc、点关闭):同取消,什么都不删', async () => { + const page = opened() + vi.mocked(window.showWarningMessage).mockImplementationOnce(async () => undefined) + try { + await page.features.remove(3) + untouched(page) + } + finally { + page.features.dispose() + } + }) +}) diff --git a/vscode/src/codeReader/features/featureViews.ts b/vscode/src/codeReader/features/featureViews.ts index 7a9795b..69a06f2 100644 --- a/vscode/src/codeReader/features/featureViews.ts +++ b/vscode/src/codeReader/features/featureViews.ts @@ -1,7 +1,7 @@ /** * 「了解一块代码」的侧栏:主栏标题栏的放大镜问要了解哪一块,占一个栏位(和收藏共用)建一栏,同时让 AI 在整个工作区里找; * 对话放在这个功能的对话页上(预览顶部的聊天,记录、进行中的那一轮都归 CodeQa 管),接着说就按上次的会话改、整栏换成新结果。 - * 栏里是 AI 交回的各个环节和条目;条目每次画、每次点时按眼下的代码找位置,找不到的画成灰的。 + * 栏里是 AI 交回的各条路径和每条路径的调用树;节点每次画、每次点时按眼下的代码找位置,找不到的画成灰的。 */ import type { Disposable, FileDecorationProvider, Memento, TreeDataProvider, TreeView, WorkspaceFolder } from 'vscode' @@ -12,33 +12,36 @@ import type { Answer } from '../qa/claudeAsk' import type { AskEvent, CodeQa, TopRequest } from '../qa/codeQa' import type { TreeSitterRuntime } from '../runtime' import type { SlotPool } from '../slots/slotPool' -import type { FeatureGroup, FeatureItem, FeatureResult } from './featureResult' +import type { CallNode, CallPath, FeatureItem, FeatureResult } from './featureResult' import type { Feature, FeatureRun } from './features' import { randomUUID } from 'node:crypto' import { EventEmitter, FileDecoration, MarkdownString, Range, TabInputCustom, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, window, workspace } from 'vscode' import { documentSymbolNodes } from '../editor/documentSymbols' -import { featureIdOf, featurePageUri, featureSubjectUri } from '../editor/pathPreview' +import { featureFolders, featureIdOf, featurePageUri, featureSubjectUri } from '../editor/pathPreview' import { findRepoSymbol } from '../editor/repoPath' import { entryContext, GROUP_CONTEXT } from '../explorer/fileEntries' import { PATH_ASK_ANCHOR } from '../qa/pathQa' import { askSlotName, focusSlot } from '../slots/slotPool' import { SLOTS, slotTitle, slotViewId } from '../slots/slots' import { locateInSource } from './featureLocate' -import { featureAnswer, featureFollowUp, featurePrompt, featureSchema, parseFeatureResult } from './featureResult' -import { FEATURE_MARKER, featureMessage, itemRow, MISSING_CONTEXT, parseFeatures, removeFeature, renameFeature, withResult } from './features' +import { childNodes, featureAnswer, featureFollowUp, featurePrompt, featureSchema, parseFeatureResult } from './featureResult' +import { FEATURE_MARKER, featureMessage, MISSING_CONTEXT, nodeRow, parseFeatures, removeFeature, renameFeature, withResult } from './features' /** workspaceState:「了解一块代码」找过的功能(Feature 的数组),按工作区记。 */ const FEATURES_KEY = 'superpowers.codeReader.features' -/** 点栏里一条的命令:参数是这一行。 */ +/** 点栏里调用树上一个节点的命令:参数是这一行。 */ export const OPEN_FEATURE_ITEM = 'superpowers.codeReader.openFeatureItem' -/** 找不到的条目行挂的 Uri 的协议:MissingRows 把它的字画成灰的,真文件在别处的颜色不受影响。 */ +/** 找不到的节点行挂的 Uri 的协议:MissingRows 把它的字画成灰的,真文件在别处的颜色不受影响。 */ const MISSING_SCHEME = 'code-reader-feature-missing' /** 文件改了、增删了,合并成一次重找。 */ const REFRESH_DELAY_MS = 300 +/** 删除功能前确认框上那个按钮的字,点了它才删 */ +const DELETE_CHOICE = '删除' + /** 栏位满了时说的话 */ const FULL_MESSAGE = `栏位满了(收藏和「了解一块代码」共用,最多 ${SLOTS.length} 个),先取消一个收藏或删掉一个功能再找` @@ -55,7 +58,7 @@ const SYMBOL_ICONS: Record = { function: 'symbol-method', } -/** 一条按眼下的代码找到的地方:文件,有符号时再加符号名字的位置(spot)和块的种类(kind,语言插件认出来的才有)。 */ +/** 一处代码按眼下的代码找到的地方:文件,有符号时再加符号名字的位置(spot)和块的种类(kind,语言插件认出来的才有)。 */ export interface FoundItem { uri: Uri folder: WorkspaceFolder @@ -63,38 +66,39 @@ export interface FoundItem { kind?: string } -/** 找条目用的语言插件:这种语言的插件(没有时 undefined)和解析器。 */ +/** 找代码位置用的语言插件:这种语言的插件(没有时 undefined)和解析器。 */ export interface ItemLanguages { adapterFor: (languageId: string) => LanguageAdapter | undefined runtime: Pick } -/** 分组行(一个环节) */ -interface GroupRow { +/** 路径行:一条路径的名字,展开是它的调用树 */ +interface PathRow { slot: number - group: FeatureGroup + callPath: CallPath } -/** 找得到的条目行:同侧栏文件行一样带着文件(FileNode),行菜单(问 AI、复制、在系统文件管理器中显示)照常用。 */ -type FoundRow = FileNode & { slot: number, item: FeatureItem, kind?: string } +/** 找得到的节点行:同侧栏文件行一样带着文件(FileNode),行菜单(问 AI、复制、在系统文件管理器中显示)照常用;callPath 是它所在的那条路径,展开时从里面找它调用的节点。 */ +type FoundRow = FileNode & { slot: number, callPath: CallPath, node: CallNode, kind?: string } -/** 找不到的条目行 */ +/** 找不到的节点行:照样能展开它调用的节点 */ interface MissingRow { slot: number - item: FeatureItem + callPath: CallPath + node: CallNode missing: true } -export type FeatureRow = GroupRow | FoundRow | MissingRow +export type FeatureRow = PathRow | FoundRow | MissingRow -/** 条目在磁盘上是哪个文件:工作区文件夹按名字找;那个文件夹不在了时没有。 */ +/** 一处代码在磁盘上是哪个文件:工作区文件夹按名字找;那个文件夹不在了时没有。 */ function itemUri(item: FeatureItem): { uri: Uri, folder: WorkspaceFolder } | undefined { const folder = workspace.workspaceFolders?.find(candidate => candidate.name === item.folder) return folder && { uri: Uri.joinPath(folder.uri, ...item.path.split('/')), folder } } /** - * 按眼下的代码找一条在哪:只有文件的看文件还在不在;有符号的,这种语言有语言插件就解析成代码块按 `类名.方法名` 找(见 locateInSource), + * 按眼下的代码找一处代码在哪:只有文件的看文件还在不在;有符号的,这种语言有语言插件就解析成代码块按 `类名.方法名` 找(见 locateInSource), * 没有插件就问 VS Code 的文档符号。文件、符号没有了时没有。 */ export async function locateItem(item: FeatureItem, languages: ItemLanguages): Promise { @@ -127,15 +131,15 @@ export async function locateItem(item: FeatureItem, languages: ItemLanguages): P } } -/** 找不到的条目行画成灰字:只认 MISSING_SCHEME 的 Uri。 */ +/** 找不到的节点行画成灰字:只认 MISSING_SCHEME 的 Uri。 */ class MissingRows implements FileDecorationProvider { provideFileDecoration(uri: Uri): FileDecoration | undefined { return uri.scheme === MISSING_SCHEME ? new FileDecoration(undefined, '找不到了', new ThemeColor('disabledForeground')) : undefined } } -/** 一个功能栏里的树:环节 → 条目。条目每次画时按眼下的代码找位置。 */ -class FeatureTree implements TreeDataProvider { +/** 一个功能栏里的树:路径 → 调用树,节点下面是它调用的节点,一层层展开。节点每次画时按眼下的代码找位置。 */ +export class FeatureTree implements TreeDataProvider { private readonly changed = new EventEmitter() readonly onDidChangeTreeData = this.changed.event @@ -147,51 +151,52 @@ class FeatureTree implements TreeDataProvider { this.changed.fire() } - /** 按眼下的代码重找各条,整棵树重画。 */ + /** 按眼下的代码重找各个节点,整棵树重画。 */ refresh(): void { this.changed.fire() } - /** 有一条是不是这个文件 */ + /** 有一个节点是不是这个文件 */ covers(uri: Uri): boolean { const key = uri.toString() - return this.result?.groups.some(group => group.items.some(item => itemUri(item)?.uri.toString() === key)) ?? false + return this.result?.paths.some(callPath => callPath.nodes.some(node => itemUri(node)?.uri.toString() === key)) ?? false } + /** 最上面一层是各条路径;路径下面是根节点,节点下面是它调用的节点(见 [childNodes]),各按眼下的代码找一遍。 */ async getChildren(row?: FeatureRow): Promise { if (!row) - return this.result?.groups.map(group => ({ slot: this.slot, group })) ?? [] - if (!('group' in row)) - return [] - return Promise.all(row.group.items.map(async (item): Promise => { - const found = await this.locate(item) + return this.result?.paths.map(callPath => ({ slot: this.slot, callPath })) ?? [] + const { callPath } = row + return Promise.all(childNodes(callPath, 'node' in row ? row.node : undefined).map(async (node): Promise => { + const found = await this.locate(node) if (!found) - return { slot: this.slot, item, missing: true } - return { slot: this.slot, item, uri: found.uri, folder: found.folder, isDirectory: false, isRoot: false, ...(found.kind !== undefined && { kind: found.kind }) } + return { slot: this.slot, callPath, node, missing: true } + return { slot: this.slot, callPath, node, uri: found.uri, folder: found.folder, isDirectory: false, isRoot: false, ...(found.kind !== undefined && { kind: found.kind }) } })) } getTreeItem(row: FeatureRow): TreeItem { - if ('group' in row) { - const item = new TreeItem(row.group.name, TreeItemCollapsibleState.Expanded) + if (!('node' in row)) { + const item = new TreeItem(row.callPath.name, TreeItemCollapsibleState.Expanded) item.contextValue = GROUP_CONTEXT return item } const found = !('missing' in row) - const { label, description, tooltip } = itemRow(row.item, found) - const item = new TreeItem(label, TreeItemCollapsibleState.None) + const { label, description, tooltip } = nodeRow(row.node, found) + const calls = childNodes(row.callPath, row.node).length > 0 + const item = new TreeItem(label, calls ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.None) item.description = description item.tooltip = new MarkdownString(tooltip) item.command = { command: OPEN_FEATURE_ITEM, title: '打开', arguments: [row] } if (!found) { item.contextValue = MISSING_CONTEXT - item.resourceUri = Uri.from({ scheme: MISSING_SCHEME, path: `/${row.item.path}` }) + item.resourceUri = Uri.from({ scheme: MISSING_SCHEME, path: `/${row.node.path}` }) item.iconPath = new ThemeIcon('circle-slash', new ThemeColor('disabledForeground')) return item } item.contextValue = entryContext(row, FEATURE_MARKER) - if (row.item.symbol === undefined) { - // 只有文件的条目:文件图标、git 改动的颜色由 VS Code 按文件加 + if (row.node.symbol === undefined) { + // 只有文件的节点:文件图标、git 改动的颜色由 VS Code 按文件加 item.resourceUri = row.uri item.iconPath = ThemeIcon.File } @@ -212,13 +217,16 @@ class FeaturePane { private readonly view: TreeView private result: FeatureResult | undefined private run: FeatureRun | undefined + /** 存着的结果是旧格式、读不了(见 [Feature]):交回新结果前栏上方提示重新找 */ + private outdated: boolean constructor(feature: Feature, locate: (item: FeatureItem) => Promise) { this.result = feature.result + this.outdated = feature.outdated === true this.tree = new FeatureTree(feature.slot, feature.result, locate) this.view = window.createTreeView(slotViewId(feature.slot), { treeDataProvider: this.tree, showCollapseAll: true }) this.view.title = slotTitle('feature', feature.name) - this.view.message = featureMessage(this.result, this.run) + this.say() } rename(name: string): void { @@ -228,14 +236,15 @@ class FeaturePane { /** AI 交回了新结果:整栏换成它。 */ show(result: FeatureResult): void { this.result = result + this.outdated = false this.tree.show(result) - this.view.message = featureMessage(this.result, this.run) + this.say() } /** 正在找、没找成、停下了、没在找(undefined)。 */ setRun(run: FeatureRun | undefined): void { this.run = run - this.view.message = featureMessage(this.result, this.run) + this.say() } covers(uri: Uri): boolean { @@ -250,6 +259,11 @@ class FeaturePane { this.view.dispose() this.tree.dispose() } + + /** 栏上方那句话按眼下的结果、在不在找重写一遍(见 [featureMessage])。 */ + private say(): void { + this.view.message = featureMessage(this.result, this.run, this.outdated) + } } /** @@ -271,7 +285,7 @@ export class FeatureViews implements Disposable { * @param state workspaceState * @param pool 和收藏共用的栏位 * @param qa 对话:顶部聊天的一轮怎么问、记录怎么存 - * @param locate 按眼下的代码找一条在哪(见 locateItem) + * @param locate 按眼下的代码找一处代码在哪(见 [locateItem]) */ constructor( private readonly state: Memento, @@ -346,11 +360,12 @@ export class FeatureViews implements Disposable { /** 点栏里一条:按眼下的代码找它在哪;找不到了说一声、这一栏重画(画成灰的)。 */ async target(row: FeatureRow | undefined): Promise { - if (!row || 'group' in row) + if (!row || !('node' in row)) return undefined - const found = await this.locate(row.item) + const { node } = row + const found = await this.locate(node) if (!found) { - void window.showInformationMessage(`找不到了:${row.item.path}${row.item.symbol === undefined ? '' : ` 里的 ${row.item.symbol}`}`) + void window.showInformationMessage(`找不到了:${node.path}${node.symbol === undefined ? '' : ` 里的 ${node.symbol}`}`) this.panes.get(row.slot)?.refresh() } return found @@ -368,11 +383,21 @@ export class FeatureViews implements Disposable { await this.save(renameFeature(this.features, slot, name)) } - /** 功能栏标题栏的「删除」:删栏(空出栏位)、删结果、删对话记录,正在找的叫停,开着的对话页关掉。 */ + /** + * 功能栏标题栏的「删除」:先弹模态框问一声,点了「删除」才删栏(空出栏位)、删结果、删问答记录和带的图片,正在找的叫停,开着的对话页关掉; + * 点「取消」、关掉对话框什么都不做。删了找不回来,所以要问。 + */ async remove(slot: number): Promise { const feature = this.features.find(item => item.slot === slot) if (!feature) return + const choice = await window.showWarningMessage( + `删掉「${feature.name}」?找到的调用树和问答记录(连同带的图片)会一起删掉,正在找的也会停下,删了找不回来。`, + { modal: true }, + DELETE_CHOICE, + ) + if (choice !== DELETE_CHOICE) + return const pane = this.panes.get(slot) this.panes.delete(slot) const pages = window.tabGroups.all.flatMap(group => group.tabs).filter(tab => tab.input instanceof TabInputCustom && featureIdOf(tab.input.uri) === feature.id) @@ -398,15 +423,15 @@ export class FeatureViews implements Disposable { * 接着上次的会话说时发 request.question 再要完整的新结果;没有会话(第一次、会话找不到了)发完整的要求,said 是用户接着说的话。 */ private async start(feature: Feature, request: TopRequest, said?: string): Promise { - const folders = workspace.workspaceFolders ?? [] + const folders = featureFolders() if (folders.length === 0) return '没打开工作区文件夹,没地方找。' const names = folders.map(folder => folder.name) const refused = await this.qa.askTop(featureSubjectUri(feature.id), request, { - prompt: featurePrompt(feature.question, folders.map(folder => ({ name: folder.name, path: folder.uri.fsPath })), said), - followUp: featureFollowUp(request.question), + prompt: featurePrompt(feature.question, folders, said), + followUp: featureFollowUp(request.question, folders), schema: featureSchema(names), - addDirs: folders.slice(1).map(folder => folder.uri.fsPath), + addDirs: folders.slice(1).map(folder => folder.path), }, answer => this.settle(feature.id, answer, names)) if (refused !== undefined) this.panes.get(feature.slot)?.setRun({ status: 'failed', error: refused }) @@ -423,7 +448,7 @@ export class FeatureViews implements Disposable { return { error: '这个功能已经删掉了。' } await this.save(withResult(this.features, id, parsed.result)) this.panes.get(feature.slot)?.show(parsed.result) - return { text: featureAnswer(parsed.result, folders.length > 1) } + return { text: featureAnswer(parsed.result, folders) } } /** CodeQa 报的进展:功能对话页上的那一轮在找、找完、没找成、停下了,改那一栏上方的话。删掉了的功能那一轮停下后不再留着。 */ @@ -446,7 +471,7 @@ export class FeatureViews implements Disposable { pane?.setRun(end.status === 'done' ? undefined : end.status === 'failed' ? { status: 'failed', error: end.error } : { status: 'stopped' }) } - /** 文件改了、增删了:等一会儿,把有条目在这些文件里的栏重找一遍。 */ + /** 文件改了、增删了:等一会儿,把有节点在这些文件里的栏重找一遍。 */ private touch(uri: Uri): void { this.touched.set(uri.toString(), uri) clearTimeout(this.timer) diff --git a/vscode/src/codeReader/features/features.test.ts b/vscode/src/codeReader/features/features.test.ts index a25db68..bc0f724 100644 --- a/vscode/src/codeReader/features/features.test.ts +++ b/vscode/src/codeReader/features/features.test.ts @@ -1,23 +1,26 @@ import type { FeatureResult } from './featureResult' import type { Feature } from './features' import { describe, expect, it } from 'vitest' -import { featureCommand, featureMessage, itemRow, parseFeatures, placeFeature, removeFeature, renameFeature, withResult } from './features' +import { featureCommand, featureMessage, nodeRow, parseFeatures, placeFeature, removeFeature, renameFeature, withResult } from './features' -const RESULT: FeatureResult = { groups: [{ name: '登录页面', items: [{ folder: 'web', path: 'src/Login.tsx', symbol: 'LoginPage', role: '登录表单' }] }] } +const RESULT: FeatureResult = { paths: [{ name: '登录', nodes: [{ id: '1', folder: 'web', path: 'src/Login.tsx', symbol: 'LoginPage', role: '登录表单' }] }] } const feature = (slot: number, overrides: Partial = {}): Feature => ({ id: `f${slot}`, question: `问题${slot}`, name: `问题${slot}`, slot, ...overrides }) describe('存着的功能', () => { - it('认得出的留下,坏的丢掉;结果读不懂的当还没有结果', () => { - const stored = [ - feature(1, { result: RESULT }), - { id: 'x', question: 'q', name: 'q' }, - 'bad', - feature(2, { result: { groups: 'bad' } as unknown as FeatureResult }), - ] + it('认得出的留下,坏的丢掉', () => { + const stored = [feature(1, { result: RESULT }), { id: 'x', question: 'q', name: 'q' }, 'bad', feature(2)] expect(parseFeatures(stored)).toEqual([feature(1, { result: RESULT }), feature(2)]) expect(parseFeatures(undefined)).toEqual([]) }) + it('结果是以前按环节存的、读不懂的:不转换,当没有结果,记下是旧格式;记下的旧格式下次读还在', () => { + const old = { groups: [{ name: '登录页面', items: [{ folder: 'web', path: 'src/Login.tsx', symbol: 'LoginPage', role: '登录表单' }] }] } + const stored = [feature(1, { result: old as unknown as FeatureResult }), feature(2, { result: 'bad' as unknown as FeatureResult })] + const parsed = parseFeatures(stored) + expect(parsed).toEqual([feature(1, { outdated: true }), feature(2, { outdated: true })]) + expect(parseFeatures(JSON.parse(JSON.stringify(parsed)))).toEqual(parsed) + }) + it('同一个栏位存了两个时只认第一个', () => { expect(parseFeatures([feature(1), { ...feature(1), id: 'other' }])).toEqual([feature(1)]) }) @@ -35,6 +38,11 @@ describe('新功能占栏位、重命名、换结果、删除', () => { expect(withResult(features, 'f1', RESULT)).toEqual([feature(1, { result: RESULT }), feature(4)]) expect(removeFeature(features, 1)).toEqual([feature(4)]) }) + + it('旧格式的功能交回新结果后不再算旧格式,别的旧格式功能照旧', () => { + const features = [feature(1, { outdated: true }), feature(4, { outdated: true })] + expect(withResult(features, 'f1', RESULT)).toEqual([feature(1, { result: RESULT }), feature(4, { outdated: true })]) + }) }) describe('功能栏标题栏上的命令', () => { @@ -45,16 +53,20 @@ describe('功能栏标题栏上的命令', () => { }) }) -describe('栏里的一行条目', () => { - const item = RESULT.groups[0].items[0] +describe('栏里调用树上的一个节点', () => { + const node = RESULT.paths[0].nodes[0] - it('名字后面淡色的是那一句话,悬停看文件、符号和那句话', () => { - expect(itemRow(item, true)).toEqual({ label: 'LoginPage', description: '登录表单', tooltip: '**src\\/Login\\.tsx** \nLoginPage \n登录表单' }) - expect(itemRow({ folder: 'web', path: '.env', role: '接口地址' }, true)).toEqual({ label: '.env', description: '接口地址', tooltip: '**\\.env** \n接口地址' }) + it('名字是符号(只有文件的是文件名),后面淡色的是那一句话,悬停看文件路径和那句话', () => { + expect(nodeRow(node, true)).toEqual({ label: 'LoginPage', description: '登录表单', tooltip: '**src\\/Login\\.tsx** \n登录表单' }) + expect(nodeRow({ id: '2', parent: '1', folder: 'web', path: '.env', role: '接口地址' }, true)).toEqual({ label: '.env', description: '接口地址', tooltip: '**\\.env** \n接口地址' }) + }) + + it('跨进程连过来的:说明前面先写怎么连的', () => { + expect(nodeRow({ id: '2', parent: '1', folder: 'server', path: 'app/auth.py', symbol: 'login', via: 'POST /login', role: '核对密码' }, true).description).toBe('POST /login · 核对密码') }) it('找不到的悬停先说找不到了', () => { - expect(itemRow(item, false).tooltip).toBe('找不到了 \n**src\\/Login\\.tsx** \nLoginPage \n登录表单') + expect(nodeRow(node, false).tooltip).toBe('找不到了 \n**src\\/Login\\.tsx** \n登录表单') }) }) @@ -70,11 +82,18 @@ describe('栏上方的一句话', () => { expect(featureMessage(RESULT, { status: 'failed', error: '等太久,已经停掉。' })).toBe('这次没改成:等太久,已经停掉。下面还是上一次的结果,可以在对话里接着说,让它再改一次。') }) - it('没在找:有结果不说话;一个环节都没有说没找到;还没有结果(停下了、重开了窗口)说可以接着说', () => { + it('没在找:有结果不说话;一条路径都没有说没找到;还没有结果(停下了、重开了窗口)说可以接着说', () => { expect(featureMessage(RESULT, undefined)).toBeUndefined() expect(featureMessage(RESULT, { status: 'stopped' })).toBeUndefined() - expect(featureMessage({ groups: [] }, undefined)).toBe('没找到和这个功能直接相关的代码,可以在对话里换个说法接着说。') + expect(featureMessage({ paths: [] }, undefined)).toBe('没找到和这个功能直接相关的代码,可以在对话里换个说法接着说。') expect(featureMessage(undefined, { status: 'stopped' })).toBe('还没有结果,可以在对话里接着说,让它再找一次。') expect(featureMessage(undefined, undefined)).toBe('还没有结果,可以在对话里接着说,让它再找一次。') }) + + it('存着的结果是旧格式:提示打开对话说一声重新找;正在找、没找成时照常说', () => { + expect(featureMessage(undefined, undefined, true)).toBe('结果是旧格式,点标题栏的「打开对话」,说一声「重新找」,就按路径和调用树重新找一次。') + expect(featureMessage(undefined, { status: 'stopped' }, true)).toBe('结果是旧格式,点标题栏的「打开对话」,说一声「重新找」,就按路径和调用树重新找一次。') + expect(featureMessage(undefined, { status: 'running' }, true)).toBe('正在找…') + expect(featureMessage(undefined, { status: 'failed', error: '没找到 Claude Code' }, true)).toBe('没找成:没找到 Claude Code。可以在对话里接着说,让它再找一次。') + }) }) diff --git a/vscode/src/codeReader/features/features.ts b/vscode/src/codeReader/features/features.ts index 50d6680..6d1d962 100644 --- a/vscode/src/codeReader/features/features.ts +++ b/vscode/src/codeReader/features/features.ts @@ -3,24 +3,28 @@ * 这里是记录怎么读、怎么改,栏里一行、栏上方一句话怎么写。不碰 VS Code。 */ -import type { FeatureItem, FeatureResult } from './featureResult' +import type { CallNode, FeatureResult } from './featureResult' import { escapeMarkdown } from '../explorer/rowDecoration' import { freeSlot, SLOTS } from '../slots/slots' import { itemLabel, parseFeatureResult } from './featureResult' -/** 一个功能:question 是用户问的那句话(对话页标签上的名字,开新会话时发给 AI),name 是栏标题(开始时就是问题),result 是 AI 最近一次交回的结果(还没交回过时没有)。 */ +/** + * 一个功能:question 是用户问的那句话(对话页标签上的名字,开新会话时发给 AI),name 是栏标题(开始时就是问题),result 是 AI 最近一次交回的结果(还没交回过时没有)。 + * `outdated` 是存着的结果是以前的格式、读不了,只好当没有结果,栏上方提示重新找一次;交回新结果后就没有了。 + */ export interface Feature { id: string question: string name: string slot: number result?: FeatureResult + outdated?: true } -/** 功能栏里找得到的条目行的 contextValue 带的标记(见 entryContext):行菜单只挂文件能用的那几样(问 AI、复制、在系统文件管理器中显示)。 */ +/** 功能栏里找得到的节点行的 contextValue 带的标记(见 entryContext):行菜单只挂文件能用的那几样(问 AI、复制、在系统文件管理器中显示)。 */ export const FEATURE_MARKER = 'feature' -/** 找不到的条目行的 contextValue:什么菜单都不挂。 */ +/** 找不到的节点行的 contextValue:什么菜单都不挂。 */ export const MISSING_CONTEXT = `${FEATURE_MARKER} missing` /** 第 slot 个功能栏标题栏上的按钮命令:openChat 打开对话、rename 重命名、remove 删除。标题栏按钮不知道点的是哪一栏,所以每个栏位各一条。 */ @@ -47,7 +51,8 @@ export function parseFeatures(value: unknown): Feature[] { if (features.some(feature => feature.slot === slot)) continue const parsed = result === undefined ? undefined : parseFeatureResult(result) - features.push({ id, question, name, slot, ...(parsed && 'result' in parsed && { result: parsed.result }) }) + const outdated = raw.outdated === true || (parsed !== undefined && 'error' in parsed) + features.push({ id, question, name, slot, ...(parsed && 'result' in parsed && { result: parsed.result }), ...(outdated && { outdated }) }) } return features } @@ -64,7 +69,7 @@ export function renameFeature(features: readonly Feature[], slot: number, name: /** id 那个功能换成这次交回的结果。 */ export function withResult(features: readonly Feature[], id: string, result: FeatureResult): Feature[] { - return features.map(feature => feature.id === id ? { ...feature, result } : feature) + return features.map(({ outdated, ...feature }) => feature.id === id ? { ...feature, result } : { ...feature, ...(outdated && { outdated }) }) } /** 删掉第 slot 栏的功能,空出这个栏位。 */ @@ -73,12 +78,16 @@ export function removeFeature(features: readonly Feature[], slot: number): Featu } /** - * 栏里一行条目怎么画:名字(符号名,只有文件的写文件名)、后面淡色的那一句话、悬停提示(文件、符号、那句话)。 - * found 是按眼下的代码找没找到;找不到的悬停先说「找不到了」。 + * 栏里调用树上一个节点的那一行怎么画:名字(符号名,只有文件的写文件名)、后面淡色的说明(跨进程连过来的先写怎么连的,再写那一句话)、 + * 悬停提示(文件路径和那一句话)。found 是按眼下的代码找没找到;找不到的悬停先说「找不到了」。 */ -export function itemRow(item: FeatureItem, found: boolean): { label: string, description: string, tooltip: string } { - const lines = [`**${escapeMarkdown(item.path)}**`, ...(item.symbol === undefined ? [] : [escapeMarkdown(item.symbol)]), escapeMarkdown(item.role)] - return { label: itemLabel(item), description: item.role, tooltip: [...(found ? [] : ['找不到了']), ...lines].join(' \n') } +export function nodeRow(node: CallNode, found: boolean): { label: string, description: string, tooltip: string } { + const lines = [`**${escapeMarkdown(node.path)}**`, escapeMarkdown(node.role)] + return { + label: itemLabel(node), + description: node.via === undefined ? node.role : `${node.via} · ${node.role}`, + tooltip: [...(found ? [] : ['找不到了']), ...lines].join(' \n'), + } } /** 一个功能眼下在不在找:running 还在找(activity 是正在做的事),failed 没找成(error 是原因),stopped 被叫停了;没在找时没有。 */ @@ -93,10 +102,10 @@ function sentence(text: string): string { } /** - * 栏上方的一句话(TreeView.message):正在找、没找成(可以在对话里接着说重试)、没找到相关的代码、还没有结果; + * 栏上方的一句话(TreeView.message):正在找、没找成(可以在对话里接着说重试)、没找到相关的代码、还没有结果、存着的结果是旧格式(`outdated`,见 [Feature]); * 有结果、又没在找时不说话。有旧结果时再找没成,旧结果留着。 */ -export function featureMessage(result: FeatureResult | undefined, run: FeatureRun | undefined): string | undefined { +export function featureMessage(result: FeatureResult | undefined, run: FeatureRun | undefined, outdated = false): string | undefined { if (run?.status === 'running') { const doing = result ? '正在按你说的改…' : '正在找…' return run.activity ? `${doing}(${run.activity})` : doing @@ -106,9 +115,12 @@ export function featureMessage(result: FeatureResult | undefined, run: FeatureRu ? `这次没改成:${sentence(run.error)}下面还是上一次的结果,可以在对话里接着说,让它再改一次。` : `没找成:${sentence(run.error)}可以在对话里接着说,让它再找一次。` } - if (!result) - return '还没有结果,可以在对话里接着说,让它再找一次。' - if (result.groups.length === 0) + if (!result) { + return outdated + ? '结果是旧格式,点标题栏的「打开对话」,说一声「重新找」,就按路径和调用树重新找一次。' + : '还没有结果,可以在对话里接着说,让它再找一次。' + } + if (result.paths.length === 0) return '没找到和这个功能直接相关的代码,可以在对话里换个说法接着说。' return undefined } diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 0264ba3..8aa8dc0 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -190,7 +190,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { ]), // 侧栏各栏的文件行传来的是 (文件 Uri, 从哪一栏点开的) commands.registerCommand(OPEN_COMMAND, (uri: Uri, from: OpenedFrom) => openFile(uri, from, supports, previews)), - // 「了解一块代码」栏里的条目行传来的是这一行:按眼下的代码找到它再打开 + // 「了解一块代码」栏里调用树上的节点行传来的是这一行:按眼下的代码找到它再打开 commands.registerCommand(OPEN_FEATURE_ITEM, async (row?: FeatureRow) => { const found = await features.target(row) if (found) diff --git a/vscode/src/codeReader/qa/asks.test.ts b/vscode/src/codeReader/qa/asks.test.ts index c580882..3e72586 100644 --- a/vscode/src/codeReader/qa/asks.test.ts +++ b/vscode/src/codeReader/qa/asks.test.ts @@ -91,13 +91,13 @@ describe('正在答的问题', () => { update: () => {}, end: async () => { ended() - return { status: 'failed', error: '交回的结果不对:没有环节列表。' } + return { status: 'failed', error: '交回的结果不对:没有路径列表。' } }, }) runs[0].answer({ text: '' }) await done await Promise.resolve() - expect(asks.currentFor('file:///a.py')).toEqual([{ askId: '1', anchor: '1', question: '问 1', text: '', status: 'failed', error: '交回的结果不对:没有环节列表。' }]) + expect(asks.currentFor('file:///a.py')).toEqual([{ askId: '1', anchor: '1', question: '问 1', text: '', status: 'failed', error: '交回的结果不对:没有路径列表。' }]) expect(asks.start(start('2', '1'), listener())).toBeUndefined() expect(asks.currentFor('file:///a.py').map(round => round.askId)).toEqual(['2']) }) diff --git a/vscode/src/codeReader/qa/linkRule.test.ts b/vscode/src/codeReader/qa/linkRule.test.ts new file mode 100644 index 0000000..4b307bb --- /dev/null +++ b/vscode/src/codeReader/qa/linkRule.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { codeLink, codeLinkRule } from './linkRule' + +describe('扩展自己写的仓库链接', () => { + it('有符号写到符号,没有只到文件;显示名里的 Markdown 记号转义掉', () => { + expect(codeLink('create_app', 'server/app/routes.py', 'create_app')).toBe('[create\\_app](server/app/routes.py#create_app)') + expect(codeLink('.env', 'web/.env')).toBe('[\\.env](web/.env)') + }) + + it('路径里成对的括号原样留着(Next.js 的路由分组)', () => { + expect(codeLink('ChatCodePage', 'frontend/src/app/(main)/chat/code/page.tsx', 'ChatCodePage')) + .toBe('[ChatCodePage](frontend/src/app/(main)/chat/code/page.tsx#ChatCodePage)') + }) + + it('空白、尖括号、百分号、不成对的括号按百分号编码', () => { + expect(codeLink('a b.md', 'docs/a b.md')).toBe('[a b\\.md](docs/a%20b.md)') + expect(codeLink('x', 'docs/100%/.md')).toBe('[x](docs/100%25/%3Cx%3E.md)') + expect(codeLink('x', 'docs/a(b.md')).toBe('[x](docs/a%28b.md)') + expect(codeLink('x', 'docs/a)b(.md')).toBe('[x](docs/a%29b%28.md)') + }) +}) + +describe('要模型把代码位置写成链接的话', () => { + it('「了解一块代码」只有一个工作区文件夹:路径从这个文件夹算起', () => { + const rule = codeLinkRule([{ name: 'web', path: '/ws/web' }]) + expect(rule).toContain('提到代码位置时写成 Markdown 链接,点了能跳过去。路径从工作区文件夹 /ws/web 算起,开头不带 / 或 ./:') + expect(rule).toContain('[OrderService.cancel](backend/app/orders/service.py#OrderService.cancel)') + expect(rule).toContain('这个文件夹外的代码(比如依赖库)不写链接') + }) + + it('几个工作区文件夹:路径以文件夹的名字开头,例子也带上', () => { + const rule = codeLinkRule([{ name: 'web', path: '/ws/web' }, { name: 'server', path: '/srv/app' }]) + expect(rule).toContain('路径的第一段是工作区文件夹的名字(web、server),后面接相对这个文件夹的路径,开头不带 / 或 ./:') + expect(rule).toContain('[OrderService.cancel](web/backend/app/orders/service.py#OrderService.cancel)') + expect(rule).toContain('[service.py:357-364](web/backend/app/orders/service.py#L357-L364)') + expect(rule).toContain('这几个文件夹外的代码(比如依赖库)不写链接') + }) +}) diff --git a/vscode/src/codeReader/qa/linkRule.ts b/vscode/src/codeReader/qa/linkRule.ts index a553794..70cd0c3 100644 --- a/vscode/src/codeReader/qa/linkRule.ts +++ b/vscode/src/codeReader/qa/linkRule.ts @@ -1,24 +1,69 @@ /** * 问 AI 时要模型怎么写回答里的代码位置:写成和注释里一样的仓库链接,代码阅读预览里点了能跳过去。 + * 扩展自己写进回答里的代码位置也按这个写法(见 [codeLink])。 */ +import type { NamedFolder } from '../editor/repoPath' +import { namesFolder } from '../editor/repoPath' +import { escapeMarkdown } from '../explorer/rowDecoration' + /** 链接打不开时(问的代码不在 git 仓库里)的写法:行内代码的绝对路径加行号。 */ const OUTSIDE_RULE = '问的代码不在 git 仓库里,提到代码位置时写行内代码:绝对路径加行号,如 `/path/to/file.py:12-20`。' /** * 要模型把代码位置写成 Markdown 链接的那段话,带一个例子。链接的写法同注释里的仓库链接([parseRepoLink](vscode/webview-ui/src/codeReader/repoLink.ts#parseRepoLink) 认的): - * 优先 `#符号`,几行写 `#L起-L止`。预览按被问的代码所在的 git 仓库根找链接里的路径,所以把这个根告诉模型。 + * 优先 `#符号`,几行写 `#L起-L止`。预览按 `base` 找链接里的路径,所以把它告诉模型。 * - * @param repoRoot 被问的代码所在的 git 仓库根(绝对路径);不在 git 仓库里时没有,链接打不开,改要行内代码的绝对路径 + * @param base 链接路径从哪儿算:字符串是被问的代码所在的 git 仓库根(绝对路径);数组是「了解一块代码」找代码用的工作区文件夹, + * 几个文件夹时路径以文件夹的名字开头(见 [namesFolder])。被问的代码不在 git 仓库里时没有,链接打不开,改要行内代码的绝对路径 */ -export function codeLinkRule(repoRoot: string | undefined): string { - if (repoRoot === undefined) +export function codeLinkRule(base: string | readonly NamedFolder[] | undefined): string { + if (base === undefined) return OUTSIDE_RULE + const { from, prefix, outside } = linkFrom(base) return [ - `提到代码位置时写成 Markdown 链接,点了能跳过去。路径从仓库根 ${repoRoot} 算起,开头不带 / 或 ./:`, + `提到代码位置时写成 Markdown 链接,点了能跳过去。${from},开头不带 / 或 ./:`, '- 指到函数、类、方法:[显示名](路径#符号),符号写函数名或「类名.方法名」。优先用这种,代码改了行号也不失效。', '- 指到函数里的几行:[文件名:起-止](路径#L起-L止),只有一行时写 #L行号。', - '例如:[OrderService.cancel](backend/app/orders/service.py#OrderService.cancel)、[service.py:357-364](backend/app/orders/service.py#L357-L364)。', - '仓库外的代码(比如依赖库)不写链接,写行内代码:绝对路径加行号,如 `/path/to/file.py:12-20`。', + `例如:[OrderService.cancel](${prefix}backend/app/orders/service.py#OrderService.cancel)、[service.py:357-364](${prefix}backend/app/orders/service.py#L357-L364)。`, + `${outside}的代码(比如依赖库)不写链接,写行内代码:绝对路径加行号,如 \`/path/to/file.py:12-20\`。`, ].join('\n') } + +/** [codeLinkRule] 里随 `base` 变的几处:路径从哪儿算起、例子里路径的开头、哪些代码算在外面。 */ +function linkFrom(base: string | readonly NamedFolder[]): { from: string, prefix: string, outside: string } { + if (typeof base === 'string') + return { from: `路径从仓库根 ${base} 算起`, prefix: '', outside: '仓库外' } + if (!namesFolder(base)) + return { from: `路径从工作区文件夹 ${base[0].path} 算起`, prefix: '', outside: '这个文件夹外' } + const names = base.map(folder => folder.name) + return { from: `路径的第一段是工作区文件夹的名字(${names.join('、')}),后面接相对这个文件夹的路径`, prefix: `${names[0]}/`, outside: '这几个文件夹外' } +} + +/** 链接地址里 CommonMark 会断开、或页面解码时会认错的字:空白、尖括号、百分号(页面按 `decodeURIComponent` 解回来)。成对的括号不算,原样留着。 */ +const UNSAFE_IN_HREF = /[%\s<>]/g + +/** 括号是不是成对:成对的 CommonMark 认作链接地址的一部分,不成对的链接就断了。 */ +function balanced(text: string): boolean { + let depth = 0 + for (const char of text) { + depth += char === '(' ? 1 : char === ')' ? -1 : 0 + if (depth < 0) + return false + } + return depth === 0 +} + +/** + * 一处代码写成的仓库链接,和 [codeLinkRule] 要模型写的一样:有符号时是 `[显示名](路径#符号)`,没有时只到文件。 + * 显示名里的 Markdown 记号转义掉;路径里 [UNSAFE_IN_HREF] 的字和不成对的括号按百分号编码,页面照样认成链接。 + * + * @param label 显示名 + * @param target 链接路径(见 [codeLinkRule] 的 `base`) + * @param symbol 文件里的符号,函数名或「类名.方法名」 + */ +export function codeLink(label: string, target: string, symbol?: string): string { + const safe = target.replace(UNSAFE_IN_HREF, encodeURIComponent) + const href = balanced(safe) ? safe : safe.replaceAll('(', '%28').replaceAll(')', '%29') + return `[${escapeMarkdown(label)}](${symbol === undefined ? href : `${href}#${symbol}`})` +} diff --git a/vscode/webview-ui/src/codeReader/markdown.test.ts b/vscode/webview-ui/src/codeReader/markdown.test.ts index 6a15dd5..dbef196 100644 --- a/vscode/webview-ui/src/codeReader/markdown.test.ts +++ b/vscode/webview-ui/src/codeReader/markdown.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' +import { featureAnswer } from '../../../src/codeReader/features/featureResult' +import { codeLink } from '../../../src/codeReader/qa/linkRule' import { renderMarkdown, renderMarkdownDiff, renderMarkdownOnce } from './markdown' import { repoImageInner } from './repoImage' +import { parseRepoLink } from './repoLink' describe('renderMarkdown', () => { it('一行文字是一个段落;段内换行是软换行,空行分段', () => { @@ -167,3 +170,42 @@ describe('问 AI 的回答里写成行内代码的代码位置', () => { expect(renderMarkdown('见 `backend/app/routes.py:96-135`')).toBe('

见 backend/app/routes.py:96-135

\n') }) }) + +describe('「了解一块代码」的回答', () => { + /** 回答里各个链接的地址,按页面认仓库链接的办法拆开 */ + const links = (html: string): unknown[] => [...html.matchAll(//g)].map(([, href]) => parseRepoLink(href.replaceAll('&', '&'))) + + it('调用树画成嵌套列表,节点是仓库链接:路径带成对的括号(Next.js 的路由分组)也认成链接,via 是行内代码', () => { + const answer = featureAnswer({ paths: [{ name: '首页发出首句', nodes: [ + { id: '1', folder: 'web', path: 'src/app/(main)/chat/code/page.tsx', symbol: 'ChatCodePage', role: '会话页' }, + { id: '2', parent: '1', folder: 'server', path: 'app/routes.py', symbol: 'create_app', via: 'POST /apps', role: '建应用' }, + { id: '3', parent: '2', folder: 'server', path: 'app/models.py', role: '应用表' }, + ] }] }, ['web', 'server']) + const html = renderMarkdown(answer, 'answer') + expect(html).toBe([ + '

1. 首页发出首句

', + '
', + '', + ].join('\n')) + expect(links(html)).toEqual([ + { path: 'web/src/app/(main)/chat/code/page.tsx', symbol: 'ChatCodePage' }, + { path: 'server/app/routes.py', symbol: 'create_app' }, + { path: 'server/app/models.py' }, + ]) + }) + + it('路径里有空白、百分号、不成对的括号:编码后照样认成链接,拆出来是原来的路径', () => { + const html = renderMarkdown([codeLink('a', 'docs/a b.md'), codeLink('b', 'docs/100%.md'), codeLink('c', 'docs/a(b.md', 'Doc')].join(' '), 'answer') + expect(links(html)).toEqual([{ path: 'docs/a b.md' }, { path: 'docs/100%.md' }, { path: 'docs/a(b.md', symbol: 'Doc' }]) + }) +})