✨ 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`)挨个试,取第一个里面找得到后面几段的。
@@ -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'
@@ -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<string, unknown>, 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<string, unknown> } }, required: string[] } } }
required: string[]
additionalProperties: boolean
}
const group = schema.properties.groups.items
const item = (group.properties.items as { items: { properties: Record<string, { enum?: string[] }>, required: string[], additionalProperties: boolean } }).items
const callPath = schema.properties.paths.items
const nodes = callPath.properties.nodes
const node = nodes.items as { properties: Record<string, { enum?: string[] }>, 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<string, unknown>): 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<string, unknown>[]): 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<string, unknown>): 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')
})
})
+153 -75
View File
@@ -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<string>()
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<string, unknown>, 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')
}
@@ -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<FoundItem | undefined> {
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<typeof FeatureViews>)
return { features, state, pool, qa }
}
/** 什么都没删:栏还在,记录、栏位、问答记录都没动 */
function untouched({ features, state, pool, qa }: ReturnType<typeof opened>): 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()
}
})
})
+78 -53
View File
@@ -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<string, string> = {
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<TreeSitterRuntime, 'parse'>
}
/** 分组行(一个环节) */
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<FoundItem | undefined> {
@@ -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<FeatureRow> {
/** 一个功能栏里的树:路径 → 调用树,节点下面是它调用的节点,一层层展开。节点每次画时按眼下的代码找位置。 */
export class FeatureTree implements TreeDataProvider<FeatureRow> {
private readonly changed = new EventEmitter<void>()
readonly onDidChangeTreeData = this.changed.event
@@ -147,51 +151,52 @@ class FeatureTree implements TreeDataProvider<FeatureRow> {
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<FeatureRow[]> {
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<FeatureRow> => {
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<FeatureRow> => {
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<FeatureRow>
private result: FeatureResult | undefined
private run: FeatureRun | undefined
/** 存着的结果是旧格式、读不了(见 [Feature]):交回新结果前栏上方提示重新找 */
private outdated: boolean
constructor(feature: Feature, locate: (item: FeatureItem) => Promise<FoundItem | undefined>) {
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<FoundItem | undefined> {
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<void> {
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<string | undefined> {
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)
+36 -17
View File
@@ -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> = {}): 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。可以在对话里接着说,让它再找一次。')
})
})
+28 -16
View File
@@ -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
}
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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'])
})
+38
View File
@@ -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%/<x>.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('这几个文件夹外的代码(比如依赖库)不写链接')
})
})
+52 -7
View File
@@ -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}`})`
}
@@ -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('<p>见 <code>backend/app/routes.py:96-135</code></p>\n')
})
})
describe('「了解一块代码」的回答', () => {
/** 回答里各个链接的地址,按页面认仓库链接的办法拆开 */
const links = (html: string): unknown[] => [...html.matchAll(/<a href="([^"]*)">/g)].map(([, href]) => parseRepoLink(href.replaceAll('&amp;', '&')))
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([
'<p><strong>1. 首页发出首句</strong></p>',
'<ul>',
'<li><a href="web/src/app/(main)/chat/code/page.tsx#ChatCodePage">ChatCodePage</a>:会话页',
'<ul>',
'<li><code>POST /apps</code> → <a href="server/app/routes.py#create_app">create_app</a>:建应用',
'<ul>',
'<li><a href="server/app/models.py">models.py</a>:应用表</li>',
'</ul>',
'</li>',
'</ul>',
'</li>',
'</ul>',
'',
].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' }])
})
})