/** * 「了解一块代码」的侧栏:主栏标题栏的放大镜问要了解哪一块,占一个栏位(和收藏共用)建一栏,同时让 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 = { 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 } /** 分组行(一个环节) */ 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 { 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 { private readonly changed = new EventEmitter() readonly onDidChangeTreeData = this.changed.event constructor(private readonly slot: number, private result: FeatureResult | undefined, private readonly locate: (item: FeatureItem) => Promise) {} /** 换成这次的结果,整棵树重画。 */ 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 { if (!row) return this.result?.groups.map(group => ({ slot: this.slot, group })) ?? [] if (!('group' in row)) return [] return Promise.all(row.group.items.map(async (item): Promise => { const found = await this.locate(item) 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 private result: FeatureResult | undefined private run: FeatureRun | undefined constructor(feature: Feature, locate: (item: FeatureItem) => Promise) { 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() private readonly disposables: Disposable[] private timer: NodeJS.Timeout | undefined /** 等着重找的文件 */ private readonly touched = new Map() /** * @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, ) { 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 { 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 { 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 { 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 { 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 { 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 { 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 { this.features = features await this.state.update(FEATURES_KEY, features.length > 0 ? features : undefined) } }