✨ feat(vscode): 新增「了解一块代码」:AI 找功能代码占一栏,对话里接着改

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

No files matched your search

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import { createBashAdapter } from '../languages/bash'
import { createPythonAdapter } from '../languages/python'
import { createRustAdapter } from '../languages/rust'
import { createTypeScriptAdapter } from '../languages/typescript'
import { runtime, WASM_DIR } from '../testSupport'
import { locateInSource } from './featureLocate'
const typescript = createTypeScriptAdapter(WASM_DIR)
const python = createPythonAdapter(WASM_DIR)
const rust = createRustAdapter(WASM_DIR)
const bash = createBashAdapter(WASM_DIR)
/** 找到的那块的名字在源码里是哪几个字、种类;找不到是 undefined */
async function find(adapter: typeof typescript, languageId: string, source: string, symbol: string): Promise<{ text: string, kind: string } | undefined> {
const found = await locateInSource(runtime, adapter, languageId, source, symbol)
return found && { text: source.slice(found.range.startIndex, found.range.endIndex), kind: found.kind }
}
const TS = `
import { api } from './api'
export function login(name: string) {
return api.post('/login', { name })
}
export class AuthService {
private token = ''
async login(name: string) {
return login(name)
}
static create() {
return new AuthService()
}
}
export const LoginPage = () => {
const submit = () => login('a')
return submit
}
`
const PY = `
class UserService:
def renew(self, user):
return self._charge(user)
class Retry:
def schedule(self):
pass
def renew_all():
pass
`
const RS = `
pub struct Order;
impl Order {
pub fn pay(&self) {}
}
fn main() {}
`
describe('按符号名在源码里找(语言插件解析出的块)', () => {
it('函数、类、组件按名字找;同名时取先出现的', async () => {
expect(await find(typescript, 'typescript', TS, 'login')).toEqual({ text: 'login', kind: 'function' })
expect(await find(typescript, 'typescript', TS, 'AuthService')).toEqual({ text: 'AuthService', kind: 'class' })
expect(await find(typescript, 'typescriptreact', TS, 'LoginPage')).toEqual({ text: 'LoginPage', kind: 'function' })
})
it('方法写成「类名.方法名」:先找类,再在里面找方法', async () => {
const found = await locateInSource(runtime, typescript, 'typescript', TS, 'AuthService.login')
expect(found?.kind).toBe('function')
// 是类里的那个 login,不是文件开头的函数
expect(found!.range.startIndex).toBeGreaterThan(TS.indexOf('class AuthService'))
expect(await find(typescript, 'typescript', TS, 'AuthService.create')).toEqual({ text: 'create', kind: 'function' })
expect(await find(typescript, 'typescript', TS, 'LoginPage.submit')).toEqual({ text: 'submit', kind: 'variable' })
})
it('类里的方法、嵌套类里的方法(Python);impl 里的方法按类型名找(Rust)', async () => {
expect(await find(python, 'python', PY, 'UserService.renew')).toEqual({ text: 'renew', kind: 'function' })
expect(await find(python, 'python', PY, 'UserService.Retry.schedule')).toEqual({ text: 'schedule', kind: 'function' })
expect(await find(python, 'python', PY, 'renew_all')).toEqual({ text: 'renew_all', kind: 'function' })
expect(await find(rust, 'rust', RS, 'Order.pay')).toEqual({ text: 'pay', kind: 'function' })
})
it('类型(interface、type)和它们的成员按声明的名字找;shell 命令不算名字', async () => {
const types = 'export interface Favorite {\n uri: string\n}\ntype Slot = number\n'
expect(await find(typescript, 'typescript', types, 'Favorite')).toEqual({ text: 'Favorite', kind: 'type' })
expect(await find(typescript, 'typescript', types, 'Favorite.uri')).toEqual({ text: 'uri', kind: 'field' })
expect(await find(typescript, 'typescript', types, 'Slot')).toEqual({ text: 'Slot', kind: 'type' })
expect(await find(rust, 'rust', 'pub type Id = u32;\n', 'Id')).toEqual({ text: 'Id', kind: 'statement' })
expect(await find(bash, 'shellscript', 'deploy() {\n echo hi\n}\n', 'deploy')).toEqual({ text: 'deploy', kind: 'function' })
expect(await find(bash, 'shellscript', 'deploy() {\n echo hi\n}\n', 'deploy.echo')).toBeUndefined()
})
it('找不到:没有这个名字、类里没有这个方法、写法不对', async () => {
expect(await find(typescript, 'typescript', TS, 'logout')).toBeUndefined()
expect(await find(typescript, 'typescript', TS, 'AuthService.logout')).toBeUndefined()
expect(await find(typescript, 'typescript', TS, 'Missing.login')).toBeUndefined()
expect(await find(typescript, 'typescript', TS, 'AuthService.')).toBeUndefined()
expect(await find(python, 'python', PY, 'UserService.schedule.renew')).toBeUndefined()
})
})
@@ -0,0 +1,59 @@
/**
* 「了解一块代码」的条目按符号名找位置:用语言插件把源码解析成代码块(同预览),在有名字的块里按 `类名.方法名` 一层层找。
* 每次画栏、点条目时按眼下的代码现找,代码改过也找得到。不碰 VS Code;语言没有插件时用 VS Code 的文档符号(见 featureViews.ts)。
*/
import type { BlockKind, CodeBlock, LanguageAdapter, Node, SourceRange } from '../contract'
import type { TreeSitterRuntime } from '../runtime'
import { ownName } from '../blockView'
import { findRepoSymbol } from '../editor/repoPath'
/** 块树上有名字的一块:名字、种类、名字在源码里的范围;子孙里有名字的挂在下面。 */
export interface NamedBlock {
name: string
kind: BlockKind
range: SourceRange
children: NamedBlock[]
}
/** 语言插件不给名字、按语法树里的声明找名字的块:类型(TypeScript 的 interface、type)、类型里的成员、别的声明语句(Rust 的 type)。 */
const DECLARATIONS = new Set<BlockKind>(['type', 'field', 'statement'])
/**
* 块的名字:函数、类、组件、变量(见 ownName),Rust 的 impl、trait(`impl Order` 里的方法写成 `Order.pay`);
* DECLARATIONS 那几种按语法树里声明的 name 字段找,export 语句先进到它导出的声明里。
*/
function nameOf(block: CodeBlock): Node | undefined {
if (block.kind === 'impl' || block.kind === 'trait')
return block.facts.name
if (!DECLARATIONS.has(block.kind))
return ownName(block)
const declaration = block.node.childForFieldName('declaration') ?? block.node
return declaration.childForFieldName('name') ?? undefined
}
/** 块树里有名字的那些;没名字的块(条件、调用语句)跳过,它里面有名字的往上提一层。 */
export function namedBlocks(blocks: readonly CodeBlock[]): NamedBlock[] {
return blocks.flatMap((block): NamedBlock[] => {
const children = namedBlocks(block.children)
const name = nameOf(block)
return name ? [{ name: name.text, kind: block.kind, range: { startIndex: name.startIndex, endIndex: name.endIndex }, children }] : children
})
}
/** 用语言插件解析 source,按 symbol(`login`、`AuthService.login`)找那一块;找不到没有。语法树用完即释放。 */
export async function locateInSource(
runtime: Pick<TreeSitterRuntime, 'parse'>,
adapter: LanguageAdapter,
languageId: string,
source: string,
symbol: string,
): Promise<NamedBlock | undefined> {
const tree = await runtime.parse(adapter, languageId, source)
try {
return findRepoSymbol(namedBlocks(adapter.toBlocks(tree.rootNode, source, languageId)), symbol)
}
finally {
tree.delete()
}
}
@@ -0,0 +1,132 @@
import type { FeatureResult } from './featureResult'
import { describe, expect, it } from 'vitest'
import { featureAnswer, featureFollowUp, featurePrompt, featureSchema, itemLabel, parseFeatureResult } from './featureResult'
const FOLDERS = ['web', 'server']
const LOGIN: FeatureResult = {
groups: [
{
name: '登录页面',
items: [
{ folder: 'web', path: 'src/pages/Login.tsx', symbol: 'LoginPage', role: '登录表单,提交账号密码' },
{ folder: 'web', path: '.env', role: '登录接口的地址' },
],
},
{
name: '校验与发令牌',
items: [{ folder: 'server', path: 'app/auth.py', symbol: 'AuthService.login', role: '核对密码、发令牌' }],
},
],
}
describe('交回的结构(--json-schema)', () => {
const schema = JSON.parse(featureSchema(FOLDERS)) as {
properties: { groups: { items: { properties: 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
it('若干环节,每个环节有名字和条目;只能交这几项', () => {
expect(schema.required).toEqual(['groups'])
expect(schema.additionalProperties).toBe(false)
expect(group.required).toEqual(['name', 'items'])
})
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)
})
})
describe('校验 AI 交回的结果', () => {
it('合格的原样收下,按交回的先后排', () => {
expect(parseFeatureResult(LOGIN, FOLDERS)).toEqual({ result: LOGIN })
})
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('一个环节都没有也收下:说明没找到相关的代码', () => {
expect(parseFeatureResult({ groups: [] }, FOLDERS)).toEqual({ result: { groups: [] } })
})
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 条没说它干什么。' })
})
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」不存在。' })
})
it('读存着的结果不查工作区文件夹:文件夹后来变了,那几条到时候画成找不到', () => {
const output = { groups: [{ name: '页面', items: [{ folder: 'gone', path: 'a.ts', role: '入口' }] }] }
expect(parseFeatureResult(output)).toEqual({ result: output })
})
})
describe('发给 AI 的话', () => {
const folders = [{ name: 'web', path: '/ws/web' }, { name: 'server', path: '/ws/server' }]
it('开新会话:要找的功能、在哪些工作区文件夹里找、交回什么、只读不改、不给行号、只列直接相关的、中文', () => {
const prompt = featurePrompt('用户登录', folders)
expect(prompt).toContain('「用户登录」')
expect(prompt).toContain('- web:/ws/web')
expect(prompt).toContain('- server:/ws/server')
expect(prompt).toMatch(/类名\.方法名/)
expect(prompt).toContain('不要写行号')
expect(prompt).toContain('只列和这个功能直接相关的')
expect(prompt).toContain('中文')
expect(prompt).not.toContain('补充')
})
it('会话找不到了重新开时,用户接着说的话跟在后面', () => {
expect(featurePrompt('用户登录', folders, '还漏了找回密码')).toMatch(/「用户登录」[\s\S]*\n\n用户补充:还漏了找回密码$/)
})
it('接着上次的会话说:用户的话,再要完整的新结果', () => {
expect(featureFollowUp('XX 不相关')).toBe('XX 不相关\n\n按这个改,交回改完之后完整的结果(全部环节和条目,不是只交改动的部分)。')
})
})
describe('对话里显示的回答', () => {
it('按环节列出条目:符号、文件、一句话', () => {
expect(featureAnswer(LOGIN, false)).toBe([
'**1. 登录页面**',
'',
'- `LoginPage`(`src/pages/Login.tsx`):登录表单,提交账号密码',
'- `.env`:登录接口的地址',
'',
'**2. 校验与发令牌**',
'',
'- `AuthService.login`(`app/auth.py`):核对密码、发令牌',
].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('没找到和这个功能直接相关的代码。')
})
})
describe('栏里条目的名字', () => {
it('有符号写符号,只有文件的写文件名', () => {
expect(itemLabel(LOGIN.groups[0].items[0])).toBe('LoginPage')
expect(itemLabel(LOGIN.groups[0].items[1])).toBe('.env')
expect(itemLabel({ folder: 'web', path: 'config/app.yaml', role: '配置' })).toBe('app.yaml')
})
})
@@ -0,0 +1,166 @@
/**
* 「了解一块代码」:AI 在整个工作区里找一个功能的代码,按 JSON Schema 交回若干「环节」,每个环节下面是文件 + 符号 + 一句话。
* 这里是交回的结构、怎么校验、发给 AI 的话、对话里怎么显示。不碰 VS Code。
*/
import * as path from 'node:path'
import { isSafeRepoPath } from '../editor/repoPath'
/**
* 一条:在哪个工作区文件夹的哪个文件(相对这个文件夹),哪个函数 / 方法 / 类 / 组件(方法写成 `类名.方法名`,配置文件这类没有函数的不写),
* 和一句话说它在这个功能里干什么。不带行号:每次按眼下的代码现找(见 featureLocate.ts),代码改过也找得到。
*/
export interface FeatureItem {
folder: string
path: string
symbol?: string
role: string
}
/** 一个环节:按功能起的名字(「登录页面」「校验与发令牌」)和下面的条目。 */
export interface FeatureGroup {
name: string
items: FeatureItem[]
}
/** AI 交回的一次结果:各环节按功能流程的先后排。一个环节都没有是没找到相关的代码。 */
export interface FeatureResult {
groups: FeatureGroup[]
}
/** 给 `--json-schema` 的结构:folders 是工作区文件夹的名字,条目的 folder 只能是其中之一。 */
export function featureSchema(folders: readonly string[]): string {
return JSON.stringify({
type: 'object',
properties: {
groups: {
type: 'array',
description: '这个功能的各个环节,按功能流程的先后排',
items: {
type: 'object',
properties: {
name: { type: 'string', description: '按功能起的环节名,如「登录页面」「登录接口」「校验与发令牌」「用户表」' },
items: {
type: 'array',
minItems: 1,
items: {
type: 'object',
properties: {
folder: { type: 'string', enum: [...new Set(folders)], description: '文件所在的工作区文件夹的名字' },
path: { type: 'string', description: '相对这个工作区文件夹的路径,用 / 分隔' },
symbol: { type: 'string', description: '函数、方法、类或组件的名字,方法写成「类名.方法名」,要能在文件里唯一找到;配置文件这类没有函数的不写' },
role: { type: 'string', description: '一句中文:它在这个功能里干什么' },
},
required: ['folder', 'path', 'role'],
additionalProperties: false,
},
},
},
required: ['name', 'items'],
additionalProperties: false,
},
},
},
required: ['groups'],
additionalProperties: false,
})
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** 去掉首尾空白的字;不是字或是空的没有。 */
function text(value: unknown): string | undefined {
const trimmed = typeof value === 'string' ? value.trim() : ''
return trimmed === '' ? undefined : trimmed
}
/**
* 校验交回的结果,合格的去掉首尾空白收下,不合格的说清哪里不对(给人看的话)。
* 给了 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 (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 { result: { groups } }
}
/** 发给 AI 的工作区文件夹:名字和磁盘上的路径。 */
export interface PromptFolder {
name: string
path: string
}
/**
* 开新会话时发给 AI 的话:要找哪个功能、在哪些工作区文件夹里找、交回什么样的结果。
* said 是会话找不到了重新开时用户接着说的话,跟在最后。
*/
export function featurePrompt(question: string, folders: readonly PromptFolder[], said?: string): string {
const lines = [
`在下面这些工作区文件夹里找出「${question}」这个功能的代码:`,
...folders.map(folder => `- ${folder.name}:${folder.path}`),
'',
'只读、搜文件,不要改任何东西。找完按给定的结构交回:',
'- 把这个功能分成几个环节,按功能流程的先后排,每个环节起一个按功能叫的名字(如「登录页面」「登录接口」「校验与发令牌」「用户表」)。',
'- 每个环节下面列出相关的代码:folder 是文件所在的工作区文件夹的名字,path 是相对这个文件夹的路径;'
+ 'symbol 是函数、方法、类或组件的名字,方法写成「类名.方法名」这样能在文件里唯一找到的写法;配置文件这类没有函数的条目不写 symbol。',
'- role 用一句中文说它在这个功能里干什么。',
'- 不要写行号。只列和这个功能直接相关的代码,顺带用到的通用工具、框架代码不要列。',
]
const prompt = lines.join('\n')
return said === undefined ? prompt : `${prompt}\n\n用户补充:${said}`
}
/** 接着上次的会话说时发的:用户的话,再要改完之后完整的结果。 */
export function featureFollowUp(said: string): string {
return `${said}\n\n按这个改,交回改完之后完整的结果(全部环节和条目,不是只交改动的部分)。`
}
/** 栏里、对话里一条的名字:有符号写符号,只有文件的写文件名。 */
export function itemLabel(item: FeatureItem): string {
return item.symbol ?? path.posix.basename(item.path)
}
/**
* 对话里显示的这一轮回答(Markdown):按环节列出各条(符号、文件、一句话),和栏里的一样。
* showFolder:几个工作区文件夹时路径前带上文件夹名。
*/
export function featureAnswer(result: FeatureResult, showFolder: boolean): string {
if (result.groups.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')
}
@@ -0,0 +1,476 @@
/**
* 「了解一块代码」的侧栏:主栏标题栏的放大镜问要了解哪一块,占一个栏位(和收藏共用)建一栏,同时让 AI 在整个工作区里找;
* 对话放在这个功能的对话页上(预览顶部的聊天,记录、进行中的那一轮都归 CodeQa 管),接着说就按上次的会话改、整栏换成新结果。
* 栏里是 AI 交回的各个环节和条目;条目每次画、每次点时按眼下的代码找位置,找不到的画成灰的。
*/
import type { Disposable, FileDecorationProvider, Memento, TreeDataProvider, TreeView, WorkspaceFolder } from 'vscode'
import type { LanguageAdapter, SourceRange } from '../contract'
import type { TargetSpot } from '../editor/openTarget'
import type { FileNode } from '../explorer/fileTree'
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 { 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 { 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'
/** workspaceState:「了解一块代码」找过的功能(Feature 的数组),按工作区记。 */
const FEATURES_KEY = 'superpowers.codeReader.features'
/** 点栏里一条的命令:参数是这一行。 */
export const OPEN_FEATURE_ITEM = 'superpowers.codeReader.openFeatureItem'
/** 找不到的条目行挂的 Uri 的协议:MissingRows 把它的字画成灰的,真文件在别处的颜色不受影响。 */
const MISSING_SCHEME = 'code-reader-feature-missing'
/** 文件改了、增删了,合并成一次重找。 */
const REFRESH_DELAY_MS = 300
/** 栏位满了时说的话 */
const FULL_MESSAGE = `栏位满了(收藏和「了解一块代码」共用,最多 ${SLOTS.length} 个),先取消一个收藏或删掉一个功能再找`
/** 符号行的图标:按语言插件认出的块的种类;问 VS Code 文档符号找到的不知道种类,用方法的图标。 */
const SYMBOL_ICONS: Record<string, string> = {
class: 'symbol-class',
exception: 'symbol-class',
impl: 'symbol-class',
trait: 'symbol-interface',
enum: 'symbol-enum',
module: 'symbol-module',
const: 'symbol-constant',
variable: 'symbol-variable',
function: 'symbol-method',
}
/** 一条按眼下的代码找到的地方:文件,有符号时再加符号名字的位置(spot)和块的种类(kind,语言插件认出来的才有)。 */
export interface FoundItem {
uri: Uri
folder: WorkspaceFolder
spot?: TargetSpot
kind?: string
}
/** 找条目用的语言插件:这种语言的插件(没有时 undefined)和解析器。 */
export interface ItemLanguages {
adapterFor: (languageId: string) => LanguageAdapter | undefined
runtime: Pick<TreeSitterRuntime, 'parse'>
}
/** 分组行(一个环节) */
interface GroupRow {
slot: number
group: FeatureGroup
}
/** 找得到的条目行:同侧栏文件行一样带着文件(FileNode),行菜单(问 AI、复制、在系统文件管理器中显示)照常用。 */
type FoundRow = FileNode & { slot: number, item: FeatureItem, kind?: string }
/** 找不到的条目行 */
interface MissingRow {
slot: number
item: FeatureItem
missing: true
}
export type FeatureRow = GroupRow | 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),
* 没有插件就问 VS Code 的文档符号。文件、符号没有了时没有。
*/
export async function locateItem(item: FeatureItem, languages: ItemLanguages): Promise<FoundItem | undefined> {
const at = itemUri(item)
if (!at || !await workspace.fs.stat(at.uri).then(() => true, () => false))
return undefined
if (item.symbol === undefined)
return at
const { symbol } = item
try {
const doc = await workspace.openTextDocument(at.uri)
const adapter = languages.adapterFor(doc.languageId)
let found: { range: SourceRange, kind?: string } | undefined
if (adapter) {
const block = await locateInSource(languages.runtime, adapter, doc.languageId, doc.getText(), symbol)
found = block && { range: block.range, kind: block.kind }
}
else {
const node = findRepoSymbol(await documentSymbolNodes(at.uri.toString()), symbol)
found = node && { range: node.selection }
}
if (!found)
return undefined
const range = new Range(doc.positionAt(found.range.startIndex), doc.positionAt(found.range.endIndex))
return { ...at, spot: { range, offset: found.range.startIndex }, ...(found.kind !== undefined && { kind: found.kind }) }
}
catch {
// 打不开成文本(二进制文件)
return undefined
}
}
/** 找不到的条目行画成灰字:只认 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> {
private readonly changed = new EventEmitter<void>()
readonly onDidChangeTreeData = this.changed.event
constructor(private readonly slot: number, private result: FeatureResult | undefined, private readonly locate: (item: FeatureItem) => Promise<FoundItem | undefined>) {}
/** 换成这次的结果,整棵树重画。 */
show(result: FeatureResult): void {
this.result = result
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
}
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)
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 }) }
}))
}
getTreeItem(row: FeatureRow): TreeItem {
if ('group' in row) {
const item = new TreeItem(row.group.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)
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.iconPath = new ThemeIcon('circle-slash', new ThemeColor('disabledForeground'))
return item
}
item.contextValue = entryContext(row, FEATURE_MARKER)
if (row.item.symbol === undefined) {
// 只有文件的条目:文件图标、git 改动的颜色由 VS Code 按文件加
item.resourceUri = row.uri
item.iconPath = ThemeIcon.File
}
else {
item.iconPath = new ThemeIcon(SYMBOL_ICONS[row.kind ?? 'function'] ?? 'symbol-method')
}
return item
}
dispose(): void {
this.changed.dispose()
}
}
/** 一个功能栏:标题是放大镜加名字,栏上方一句话说正在找、没找成、没有结果(见 featureMessage)。占上栏位时建、删掉时销毁。 */
class FeaturePane {
private readonly tree: FeatureTree
private readonly view: TreeView<FeatureRow>
private result: FeatureResult | undefined
private run: FeatureRun | undefined
constructor(feature: Feature, locate: (item: FeatureItem) => Promise<FoundItem | undefined>) {
this.result = feature.result
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)
}
rename(name: string): void {
this.view.title = slotTitle('feature', name)
}
/** AI 交回了新结果:整栏换成它。 */
show(result: FeatureResult): void {
this.result = result
this.tree.show(result)
this.view.message = featureMessage(this.result, this.run)
}
/** 正在找、没找成、停下了、没在找(undefined)。 */
setRun(run: FeatureRun | undefined): void {
this.run = run
this.view.message = featureMessage(this.result, this.run)
}
covers(uri: Uri): boolean {
return this.tree.covers(uri)
}
refresh(): void {
this.tree.refresh()
}
dispose(): void {
this.view.dispose()
this.tree.dispose()
}
}
/**
* 「了解一块代码」:找过的功能各占一栏(和收藏共用栏位,见 SlotPool),记在 workspaceState 里,重开窗口栏还在。
* 找、接着说都交给 CodeQa 的顶部聊天(askTop):会话、进行中的那一轮、对话记录都在那里,关掉对话页也不中断;
* 这里拼发给 AI 的话、校验交回的结果、换栏里的树,跟着 CodeQa 报的进展改栏上方那句话。
* 打开对话页、打开文件不在这里做(要用预览),只给出要打开什么(chatPage、target)。
*/
export class FeatureViews implements Disposable {
private features: Feature[]
/** 栏位号 → 那一栏 */
private readonly panes = new Map<number, FeaturePane>()
private readonly disposables: Disposable[]
private timer: NodeJS.Timeout | undefined
/** 等着重找的文件 */
private readonly touched = new Map<string, Uri>()
/**
* @param state workspaceState
* @param pool 和收藏共用的栏位
* @param qa 对话:顶部聊天的一轮怎么问、记录怎么存
* @param locate 按眼下的代码找一条在哪(见 locateItem)
*/
constructor(
private readonly state: Memento,
private readonly pool: SlotPool,
private readonly qa: CodeQa,
private readonly locate: (item: FeatureItem) => Promise<FoundItem | undefined>,
) {
this.features = parseFeatures(state.get(FEATURES_KEY))
for (const feature of this.features) {
this.open(feature)
void pool.occupy(feature.slot, 'feature')
}
const watcher = workspace.createFileSystemWatcher('**/*')
this.disposables = [
watcher,
watcher.onDidChange(uri => this.touch(uri)),
watcher.onDidCreate(uri => this.touch(uri)),
watcher.onDidDelete(uri => this.touch(uri)),
workspace.onDidChangeWorkspaceFolders(() => this.refreshAll()),
window.registerFileDecorationProvider(new MissingRows()),
qa.onDidAnswer(event => this.answered(event)),
]
}
/**
* 主栏标题栏的「了解一块代码」:问要了解哪一块,占最小的空栏位建一栏(栏名就是问的话)、亮出来,让 AI 开始找。
* 返回这个功能的对话页(要打开它看找的过程);没问、栏位满了、没打开工作区文件夹时没有。
*/
async find(): Promise<Uri | undefined> {
if (!workspace.workspaceFolders?.length) {
void window.showWarningMessage('先打开工作区文件夹,再了解里面的代码')
return undefined
}
if (this.pool.free() === undefined) {
void window.showWarningMessage(FULL_MESSAGE)
return undefined
}
const question = (await window.showInputBox({
title: '了解一块代码',
prompt: '你想要了解哪一块的代码',
placeHolder: '例如:用户登录、会员自动续费',
validateInput: text => text.trim() ? undefined : '说说想了解哪一块',
}))?.trim()
if (!question)
return undefined
const slot = this.pool.free()
if (slot === undefined) {
void window.showWarningMessage(FULL_MESSAGE)
return undefined
}
const feature: Feature = { id: randomUUID(), question, name: question, slot }
this.open(feature)
await Promise.all([this.save([...this.features, feature]), this.pool.occupy(slot, 'feature')])
await focusSlot(slot)
await this.start(feature, { askId: randomUUID(), question, images: [] })
return featurePageUri(feature.id, feature.question)
}
/** 功能对话页里接着说(subject 见 featureSubjectUri):AI 接着上次的会话改,交回完整的新结果。说不了时返回给人看的原因。 */
async ask(subject: Uri, request: TopRequest): Promise<string | undefined> {
const feature = this.features.find(item => item.id === featureIdOf(subject))
if (!feature)
return '这个功能已经删掉了。'
return this.start(feature, request, request.question)
}
/** 第 slot 栏的对话页;这一栏不是功能时没有。 */
chatPage(slot: number): Uri | undefined {
const feature = this.features.find(item => item.slot === slot)
return feature && featurePageUri(feature.id, feature.question)
}
/** 点栏里一条:按眼下的代码找它在哪;找不到了说一声、这一栏重画(画成灰的)。 */
async target(row: FeatureRow | undefined): Promise<FoundItem | undefined> {
if (!row || 'group' in row)
return undefined
const found = await this.locate(row.item)
if (!found) {
void window.showInformationMessage(`找不到了:${row.item.path}${row.item.symbol === undefined ? '' : ` 里的 ${row.item.symbol}`}`)
this.panes.get(row.slot)?.refresh()
}
return found
}
/** 功能栏标题栏的「重命名」:改第 slot 栏的标题。 */
async rename(slot: number): Promise<void> {
const feature = this.features.find(item => item.slot === slot)
if (!feature)
return
const name = await askSlotName('重命名', feature.name)
if (name === undefined)
return
this.panes.get(slot)?.rename(name)
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 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)
await Promise.all([
this.save(removeFeature(this.features, slot)),
this.pool.release(slot),
this.qa.deleteTop(featureSubjectUri(feature.id)),
...(pages.length > 0 ? [window.tabGroups.close(pages)] : []),
])
pane?.dispose()
}
dispose(): void {
clearTimeout(this.timer)
for (const disposable of this.disposables)
disposable.dispose()
for (const pane of this.panes.values())
pane.dispose()
}
/**
* 让 AI 找一轮:只读、按 featureSchema 交结果,在第一个工作区文件夹里跑、别的加进来读。
* 接着上次的会话说时发 request.question 再要完整的新结果;没有会话(第一次、会话找不到了)发完整的要求,said 是用户接着说的话。
*/
private async start(feature: Feature, request: TopRequest, said?: string): Promise<string | undefined> {
const folders = workspace.workspaceFolders ?? []
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),
schema: featureSchema(names),
addDirs: folders.slice(1).map(folder => folder.uri.fsPath),
}, answer => this.settle(feature.id, answer, names))
if (refused !== undefined)
this.panes.get(feature.slot)?.setRun({ status: 'failed', error: refused })
return refused
}
/** AI 答完了:交回的结构合格就存下、整栏换成它,返回对话里显示的回答;不合格说清哪里不对。 */
private async settle(id: string, answer: Answer, folders: readonly string[]): Promise<{ text: string } | { error: string }> {
const parsed = parseFeatureResult(answer.output, folders)
if ('error' in parsed)
return parsed
const feature = this.features.find(item => item.id === id)
if (!feature)
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) }
}
/** CodeQa 报的进展:功能对话页上的那一轮在找、找完、没找成、停下了,改那一栏上方的话。删掉了的功能那一轮停下后不再留着。 */
private answered(event: AskEvent): void {
const id = featureIdOf(Uri.parse(event.owner))
if (id === undefined)
return
const feature = this.features.find(item => item.id === id)
if (!feature) {
if (event.kind === 'end')
this.qa.forgetAsk(featureSubjectUri(id), PATH_ASK_ANCHOR)
return
}
const pane = this.panes.get(feature.slot)
if (event.kind === 'update') {
pane?.setRun({ status: 'running', ...(event.ask.activity !== undefined && { activity: event.ask.activity }) })
return
}
const { end } = event
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)
this.timer = setTimeout(() => {
const uris = [...this.touched.values()]
this.touched.clear()
for (const pane of this.panes.values()) {
if (uris.some(touched => pane.covers(touched)))
pane.refresh()
}
}, REFRESH_DELAY_MS)
}
private refreshAll(): void {
for (const pane of this.panes.values())
pane.refresh()
}
private open(feature: Feature): void {
this.panes.set(feature.slot, new FeaturePane(feature, this.locate))
}
private async save(features: Feature[]): Promise<void> {
this.features = features
await this.state.update(FEATURES_KEY, features.length > 0 ? features : undefined)
}
}
@@ -0,0 +1,80 @@
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'
const RESULT: FeatureResult = { groups: [{ name: '登录页面', items: [{ 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 }),
]
expect(parseFeatures(stored)).toEqual([feature(1, { result: RESULT }), feature(2)])
expect(parseFeatures(undefined)).toEqual([])
})
it('同一个栏位存了两个时只认第一个', () => {
expect(parseFeatures([feature(1), { ...feature(1), id: 'other' }])).toEqual([feature(1)])
})
})
describe('新功能占栏位、重命名、换结果、删除', () => {
it('和收藏共用栏位:占没被占的里面最小的,满了没有', () => {
expect(placeFeature([1, 2, 4])).toBe(3)
expect(placeFeature(Array.from({ length: 30 }, (_, index) => index + 1))).toBeUndefined()
})
it('重命名只改那一栏的名字,换结果只换那一个,删除只去掉那一个', () => {
const features = [feature(1), feature(4)]
expect(renameFeature(features, 4, '续费')).toEqual([feature(1), feature(4, { name: '续费' })])
expect(withResult(features, 'f1', RESULT)).toEqual([feature(1, { result: RESULT }), feature(4)])
expect(removeFeature(features, 1)).toEqual([feature(4)])
})
})
describe('功能栏标题栏上的命令', () => {
it('每个栏位各有打开对话、重命名、删除', () => {
expect(featureCommand('openChat', 3)).toBe('superpowers.codeReader.openFeatureChat3')
expect(featureCommand('rename', 3)).toBe('superpowers.codeReader.renameFeature3')
expect(featureCommand('remove', 30)).toBe('superpowers.codeReader.removeFeature30')
})
})
describe('栏里的一行条目', () => {
const item = RESULT.groups[0].items[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(itemRow(item, false).tooltip).toBe('找不到了 \n**src\\/Login\\.tsx** \nLoginPage \n登录表单')
})
})
describe('栏上方的一句话', () => {
it('正在找:第一次是「正在找…」,有结果后再找是按你说的改;带上正在做的事', () => {
expect(featureMessage(undefined, { status: 'running' })).toBe('正在找…')
expect(featureMessage(undefined, { status: 'running', activity: '读取 a.ts' })).toBe('正在找…(读取 a.ts)')
expect(featureMessage(RESULT, { status: 'running' })).toBe('正在按你说的改…')
})
it('找失败了:说原因、可以在对话里接着说重试;有旧结果时旧的留着', () => {
expect(featureMessage(undefined, { status: 'failed', error: '没找到 Claude Code' })).toBe('没找成:没找到 Claude Code。可以在对话里接着说,让它再找一次。')
expect(featureMessage(RESULT, { status: 'failed', error: '等太久,已经停掉。' })).toBe('这次没改成:等太久,已经停掉。下面还是上一次的结果,可以在对话里接着说,让它再改一次。')
})
it('没在找:有结果不说话;一个环节都没有说没找到;还没有结果(停下了、重开了窗口)说可以接着说', () => {
expect(featureMessage(RESULT, undefined)).toBeUndefined()
expect(featureMessage(RESULT, { status: 'stopped' })).toBeUndefined()
expect(featureMessage({ groups: [] }, undefined)).toBe('没找到和这个功能直接相关的代码,可以在对话里换个说法接着说。')
expect(featureMessage(undefined, { status: 'stopped' })).toBe('还没有结果,可以在对话里接着说,让它再找一次。')
expect(featureMessage(undefined, undefined)).toBe('还没有结果,可以在对话里接着说,让它再找一次。')
})
})
+114
View File
@@ -0,0 +1,114 @@
/**
* 「了解一块代码」找过的功能:问的话、栏标题、占哪个栏位、AI 最近一次交回的结果,按工作区存着。
* 这里是记录怎么读、怎么改,栏里一行、栏上方一句话怎么写。不碰 VS Code。
*/
import type { FeatureItem, FeatureResult } from './featureResult'
import { escapeMarkdown } from '../explorer/rowDecoration'
import { freeSlot, SLOTS } from '../slots/slots'
import { itemLabel, parseFeatureResult } from './featureResult'
/** 一个功能:question 是用户问的那句话(对话页标签上的名字,开新会话时发给 AI),name 是栏标题(开始时就是问题),result 是 AI 最近一次交回的结果(还没交回过时没有)。 */
export interface Feature {
id: string
question: string
name: string
slot: number
result?: FeatureResult
}
/** 功能栏里找得到的条目行的 contextValue 带的标记(见 entryContext):行菜单只挂文件能用的那几样(问 AI、复制、在系统文件管理器中显示)。 */
export const FEATURE_MARKER = 'feature'
/** 找不到的条目行的 contextValue:什么菜单都不挂。 */
export const MISSING_CONTEXT = `${FEATURE_MARKER} missing`
/** 第 slot 个功能栏标题栏上的按钮命令:openChat 打开对话、rename 重命名、remove 删除。标题栏按钮不知道点的是哪一栏,所以每个栏位各一条。 */
export function featureCommand(action: 'openChat' | 'rename' | 'remove', slot: number): string {
const name = { openChat: 'openFeatureChat', rename: 'renameFeature', remove: 'removeFeature' }[action]
return `superpowers.codeReader.${name}${slot}`
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** 读 workspaceState 里存的功能;认不出的丢掉,同一个栏位存了两个时只认第一个,结果读不懂的当还没有结果。 */
export function parseFeatures(value: unknown): Feature[] {
if (!Array.isArray(value))
return []
const features: Feature[] = []
for (const raw of value) {
if (!isRecord(raw))
continue
const { id, question, name, slot, result } = raw
if (typeof id !== 'string' || typeof question !== 'string' || typeof name !== 'string' || typeof slot !== 'number' || !SLOTS.includes(slot))
continue
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 }) })
}
return features
}
/** 新功能占哪个栏位:和收藏共用,taken 是两者眼下占着的全部栏位;满了没有。 */
export function placeFeature(taken: Iterable<number>): number | undefined {
return freeSlot(taken)
}
/** 第 slot 栏的功能改叫 name,栏位不动。 */
export function renameFeature(features: readonly Feature[], slot: number, name: string): Feature[] {
return features.map(feature => feature.slot === slot ? { ...feature, name } : feature)
}
/** id 那个功能换成这次交回的结果。 */
export function withResult(features: readonly Feature[], id: string, result: FeatureResult): Feature[] {
return features.map(feature => feature.id === id ? { ...feature, result } : feature)
}
/** 删掉第 slot 栏的功能,空出这个栏位。 */
export function removeFeature(features: readonly Feature[], slot: number): Feature[] {
return features.filter(feature => feature.slot !== slot)
}
/**
* 栏里一行条目怎么画:名字(符号名,只有文件的写文件名)、后面淡色的那一句话、悬停提示(文件、符号、那句话)。
* 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') }
}
/** 一个功能眼下在不在找:running 还在找(activity 是正在做的事),failed 没找成(error 是原因),stopped 被叫停了;没在找时没有。 */
export type FeatureRun
= | { status: 'running', activity?: string }
| { status: 'failed', error: string }
| { status: 'stopped' }
/** 句子没有句末标点时补个句号,后面好接下一句。 */
function sentence(text: string): string {
return /[。!?.!?]$/.test(text) ? text : `${text}。`
}
/**
* 栏上方的一句话(TreeView.message):正在找、没找成(可以在对话里接着说重试)、没找到相关的代码、还没有结果;
* 有结果、又没在找时不说话。有旧结果时再找没成,旧结果留着。
*/
export function featureMessage(result: FeatureResult | undefined, run: FeatureRun | undefined): string | undefined {
if (run?.status === 'running') {
const doing = result ? '正在按你说的改…' : '正在找…'
return run.activity ? `${doing}(${run.activity})` : doing
}
if (run?.status === 'failed') {
return result
? `这次没改成:${sentence(run.error)}下面还是上一次的结果,可以在对话里接着说,让它再改一次。`
: `没找成:${sentence(run.error)}可以在对话里接着说,让它再找一次。`
}
if (!result)
return '还没有结果,可以在对话里接着说,让它再找一次。'
if (result.groups.length === 0)
return '没找到和这个功能直接相关的代码,可以在对话里换个说法接着说。'
return undefined
}