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

477 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 「了解一块代码」的侧栏:主栏标题栏的放大镜问要了解哪一块,占一个栏位(和收藏共用)建一栏,同时让 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)
}
}