✨ feat(vscode): 新增「了解一块代码」:AI 找功能代码占一栏,对话里接着改
主栏标题栏的放大镜问要了解哪一块,占一个栏位建一栏(和收藏共用 30 个栏位,视图改名 slot1..30,按 slotKindN 分收藏 / 功能挂标题栏按钮),同时开对话页让 Claude 只读地在全部工作区文件夹里找,按 --json-schema 交回环节 → 文件 + 符号 + 一句话。 对话复用预览顶部的聊天(CodeQa.askTop:会话续接、进行中的一轮、记录存 workspaceState);结构不合格算这一轮没答成、留着能再画出来。 条目每次画、点时按眼下的代码找:有语言插件的解析成代码块按「类名.方法名」找,没有的问 VS Code 文档符号;找不到画成灰的。
This commit is contained in:
1 parent
16d27004d8
commit
880bf7fc57
38 files changed
+3386
-378
No files matched your search
@@ -41,6 +41,9 @@ export function describeToolUse(name: string, input: Record<string, unknown>): s
|
||||
case 'Grep':
|
||||
case 'Glob':
|
||||
return `搜索 ${truncate(asString(input.pattern))}`
|
||||
// 要了结构化结果(--json-schema)时,模型调这个工具把结果交回来
|
||||
case 'StructuredOutput':
|
||||
return '整理结果'
|
||||
default:
|
||||
return `调用 ${name}`
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ function colorLines({ owner, roles }: Coloring, source: string, lines: Line[], s
|
||||
* 这块本身就是哪个名字的定义:函数名、类名,只声明一个变量(或一个解构模式)时的变量名。
|
||||
* 导入、一条语句声明几个变量时没有,它们的名字各是属性表里的一行(PropertyRow.defines)。
|
||||
*/
|
||||
function ownName(block: CodeBlock): Node | undefined {
|
||||
export function ownName(block: CodeBlock): Node | undefined {
|
||||
switch (block.kind) {
|
||||
case 'function':
|
||||
case 'class':
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { DocumentSymbol, Range } from 'vscode'
|
||||
import type { SourceRange } from '../contract'
|
||||
import type { SymbolNode } from '../symbolLinks'
|
||||
import { commands, Uri, workspace } from 'vscode'
|
||||
|
||||
/**
|
||||
* 一个文件的文档符号(`vscode.executeDocumentSymbolProvider`,由这种语言的扩展给):先把文件读进来(不显示),范围换成那个文件里的下标。
|
||||
* 没装这种语言的扩展时是空的。注释里的仓库链接、「了解一块代码」在没有语言插件的文件里找符号都用它。
|
||||
*/
|
||||
export async function documentSymbolNodes(uri: string): Promise<SymbolNode[]> {
|
||||
const doc = await workspace.openTextDocument(Uri.parse(uri))
|
||||
const symbols = await commands.executeCommand<DocumentSymbol[] | undefined>('vscode.executeDocumentSymbolProvider', doc.uri)
|
||||
const span = (range: Range): SourceRange => ({ startIndex: doc.offsetAt(range.start), endIndex: doc.offsetAt(range.end) })
|
||||
const node = (symbol: DocumentSymbol): SymbolNode => ({
|
||||
name: symbol.name,
|
||||
range: span(symbol.range),
|
||||
selection: span(symbol.selectionRange),
|
||||
children: (symbol.children ?? []).map(node),
|
||||
})
|
||||
return Array.isArray(symbols) ? symbols.map(node) : []
|
||||
}
|
||||
@@ -98,8 +98,8 @@ export interface QaMessage {
|
||||
threads: QaThreadView[]
|
||||
/** 这一页顶部、按路径记的问答;不挂在块上 */
|
||||
path: QaEntryView[]
|
||||
/** 顶部问的是这个文件还是这个文件夹 */
|
||||
subject: 'file' | 'folder'
|
||||
/** 顶部问的是这个文件、这个文件夹,还是「了解一块代码」找的一个功能 */
|
||||
subject: 'file' | 'folder' | 'feature'
|
||||
current: AskRound[]
|
||||
/** 块下面的重写记录,按块的记号 */
|
||||
rewrites: QaThreadView[]
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
/**
|
||||
* 文件夹、以及代码阅读不认识的文件,用这个协议打开代码阅读预览:
|
||||
* 文档是空的,页面上没有代码块,只在顶部聊天。源码文件直接开它自己的预览。
|
||||
* 「了解一块代码」找的功能的对话页也用这个协议,只是对的不是路径,是功能。
|
||||
*/
|
||||
|
||||
import type { Uri, WorkspaceFolder } from 'vscode'
|
||||
import type { WorkspaceFolder } from 'vscode'
|
||||
import * as path from 'node:path'
|
||||
import { workspace } from 'vscode'
|
||||
import { relativeQaKey } from '../qa/pathQa'
|
||||
import { Uri, workspace } from 'vscode'
|
||||
import { featureQaKey, pathQaKey, relativeQaKey } from '../qa/pathQa'
|
||||
|
||||
export const PATH_PREVIEW_SCHEME = 'code-reader-path'
|
||||
|
||||
export type PathSubject = 'file' | 'folder'
|
||||
|
||||
/** 预览顶部的聊天问的是什么:文件、文件夹,或「了解一块代码」找的一个功能 */
|
||||
export type TopSubject = PathSubject | 'feature'
|
||||
|
||||
/** 功能对话页的查询串:后面跟着功能的 id */
|
||||
const FEATURE_QUERY = 'feature='
|
||||
|
||||
/** 「了解一块代码」一个功能的对话页:路径是问的那句话(标签页上显示它,`/` 换成全角的免得被当成目录),查询串带着功能的 id。 */
|
||||
export function featurePageUri(id: string, question: string): Uri {
|
||||
return Uri.from({ scheme: PATH_PREVIEW_SCHEME, path: `/${question.replaceAll('/', '/')}`, query: `${FEATURE_QUERY}${id}` })
|
||||
}
|
||||
|
||||
/** 一个功能的问答按这个 Uri 记、按它对事件:对话页标签上叫什么都是它。 */
|
||||
export function featureSubjectUri(id: string): Uri {
|
||||
return Uri.from({ scheme: PATH_PREVIEW_SCHEME, path: '/', query: `${FEATURE_QUERY}${id}` })
|
||||
}
|
||||
|
||||
/** 功能对话页(或 featureSubjectUri)对的功能 id;别的 Uri 没有。 */
|
||||
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
|
||||
}
|
||||
|
||||
export function pathPreviewUri(target: Uri, kind: PathSubject): Uri {
|
||||
return target.with({ scheme: PATH_PREVIEW_SCHEME, query: kind, fragment: '' })
|
||||
}
|
||||
@@ -25,6 +47,14 @@ export function pathPreviewOf(uri: Uri): { uri: Uri, kind: PathSubject } | undef
|
||||
return { uri: uri.with({ scheme: 'file', query: '', fragment: '' }), kind: uri.query }
|
||||
}
|
||||
|
||||
/** 预览这一页顶部问的是什么:功能对话页是那个功能(featureSubjectUri),路径页还原成真实文件或文件夹,源码预览就是文档自己。 */
|
||||
export function topSubjectOf(uri: Uri): { uri: Uri, kind: TopSubject } {
|
||||
const feature = featureIdOf(uri)
|
||||
if (feature !== undefined)
|
||||
return { uri: featureSubjectUri(feature), kind: 'feature' }
|
||||
return pathPreviewOf(uri) ?? { uri, kind: 'file' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条路径在哪个工作区文件夹、相对路径是什么。
|
||||
* 工作区根本身的相对路径是空字符串(locate 不认根,顶部问答要认)。
|
||||
@@ -36,3 +66,17 @@ export function pathQaLocation(uri: Uri): { folder: WorkspaceFolder, key: string
|
||||
const key = relativeQaKey(path.posix.normalize(folder.uri.path), path.posix.normalize(uri.path))
|
||||
return key === undefined ? undefined : { folder, key }
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部问答记在哪:在哪个工作区文件夹(claude 在它里面跑,带的图片存在它的 `.spx/code-qa/`)、记录表里的键(见 pathQa.ts)。
|
||||
* 文件、文件夹按「工作区文件夹 + 相对路径」;功能对话页按功能的 id,记在第一个工作区文件夹。不在工作区里、没打开文件夹时没有。
|
||||
*/
|
||||
export function topQaLocation(uri: Uri): { folder: WorkspaceFolder, key: string } | undefined {
|
||||
const feature = featureIdOf(uri)
|
||||
if (feature !== undefined) {
|
||||
const folder = workspace.workspaceFolders?.[0]
|
||||
return folder && { folder, key: featureQaKey(feature) }
|
||||
}
|
||||
const at = pathQaLocation(uri)
|
||||
return at && { folder: at.folder, key: pathQaKey(at.folder.uri.toString(), at.key) }
|
||||
}
|
||||
@@ -26,7 +26,7 @@ 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', () => ({ pathPreviewOf: () => undefined }))
|
||||
vi.mock('./pathPreview', () => ({ PATH_PREVIEW_SCHEME: 'code-reader-path', topSubjectOf: (uri: unknown) => ({ uri, kind: 'file' }) }))
|
||||
vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() }))
|
||||
|
||||
const SOURCE = ['// 旧说明', 'export function load() {}']
|
||||
@@ -51,6 +51,7 @@ function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise<str
|
||||
{ onDidChange: subscribe },
|
||||
{ onDidChange: subscribe },
|
||||
async () => false,
|
||||
async () => undefined,
|
||||
] as unknown as ConstructorParameters<typeof PreviewEditorProvider>)
|
||||
provider.resolveCustomTextEditor(...[
|
||||
document,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { CustomTextEditorProvider, Disposable, DocumentSymbol, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
|
||||
import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
|
||||
import type { ClaudeImage } from '../../cc/spawnClaude'
|
||||
import type { IndexFind } from '../callTree/endpoint'
|
||||
import type { CommentEdit, CommentJob, WriteResult } from '../commentRewrite'
|
||||
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange } from '../contract'
|
||||
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference } from '../contract'
|
||||
import type { DiffBaseSource } from '../diff/diffBase'
|
||||
import type { OldDocument, SentDocument } from '../diff/previewDiff'
|
||||
import type { WorkspaceEntries } from '../entries/workspaceEntries'
|
||||
import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution'
|
||||
import type { CodeQa } from '../qa/codeQa'
|
||||
import type { CodeQa, TopRequest } from '../qa/codeQa'
|
||||
import type { CodeReaderRegistry } from '../registry'
|
||||
import type { LookupSources } from '../ruleLookups'
|
||||
import type { TreeSitterRuntime } from '../runtime'
|
||||
@@ -16,6 +16,7 @@ import type { BlockFilter } from './blockFilter'
|
||||
import type { FoundLocation } from './definitions'
|
||||
import type { DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
|
||||
import type { TargetSpot } from './openTarget'
|
||||
import type { TopSubject } from './pathPreview'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { commands, EndOfLine, Position, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode'
|
||||
import { logger } from '../../logging/logger'
|
||||
@@ -38,10 +39,11 @@ import { definitionSiteNames, namesOnCard, peekBlock } from './definitionPeek'
|
||||
import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
|
||||
import { DefinitionSources } from './definitionSources'
|
||||
import { documentMessage } from './documentMessage'
|
||||
import { documentSymbolNodes } from './documentSymbols'
|
||||
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
|
||||
import { occurrencesOf } from './occurrences'
|
||||
import { languageOf, openingFor } from './openTarget'
|
||||
import { pathPreviewOf } from './pathPreview'
|
||||
import { PATH_PREVIEW_SCHEME, topSubjectOf } from './pathPreview'
|
||||
import { findRepoSymbol, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath'
|
||||
|
||||
export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
|
||||
@@ -81,6 +83,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
readonly entries: Pick<WorkspaceEntries, 'namesOf' | 'onDidChange'>,
|
||||
/** 在代码阅读侧栏的树上定位一个目录(仓库链接指到目录时);树上没有它时返回 false */
|
||||
readonly revealInTree: (uri: Uri) => Promise<boolean>,
|
||||
/** 「了解一块代码」的对话页里接着说(subject 是那个功能,见 featureSubjectUri);说不了时返回给人看的原因 */
|
||||
readonly askFeature: (subject: Uri, request: TopRequest) => Promise<string | undefined>,
|
||||
) {}
|
||||
|
||||
/** 侧栏「问 AI」打开预览,并把光标放进顶部输入框。已经开着的就揭到前面再聚焦。 */
|
||||
@@ -103,7 +107,7 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
|
||||
resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void {
|
||||
// 问答带的图片(工作区文件夹的 .spx/code-qa/)也要能读,历史里才画得出缩略图
|
||||
const imageRoot = this.qa.imageRoot(pathPreviewOf(document.uri)?.uri ?? document.uri)
|
||||
const imageRoot = this.qa.imageRoot(topSubjectOf(document.uri).uri)
|
||||
panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri), ...(imageRoot ? [imageRoot] : [])] }
|
||||
panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html')
|
||||
const key = document.uri.toString()
|
||||
@@ -339,7 +343,8 @@ class Preview {
|
||||
return
|
||||
const request = ++this.latest
|
||||
this.callTrees.cancel()
|
||||
if (pathPreviewOf(this.document.uri)) {
|
||||
// 路径页、功能对话页:文档是空的,只在顶部聊天
|
||||
if (this.document.uri.scheme === PATH_PREVIEW_SCHEME) {
|
||||
this.sent = undefined
|
||||
const reveal = this.pendingReveal
|
||||
this.pendingReveal = undefined
|
||||
@@ -497,9 +502,9 @@ class Preview {
|
||||
this.post(this.owner.qa.view(subject.uri, file => this.panel.webview.asWebviewUri(file).toString(), subject.kind))
|
||||
}
|
||||
|
||||
/** 这一页问的是哪个文件或文件夹。路径页还原成真实路径,源码预览就是文档自己。 */
|
||||
private subject(): { uri: Uri, kind: 'file' | 'folder' } {
|
||||
return pathPreviewOf(this.document.uri) ?? { uri: this.document.uri, kind: 'file' }
|
||||
/** 这一页顶部问的是什么(见 topSubjectOf)。 */
|
||||
private subject(): { uri: Uri, kind: TopSubject } {
|
||||
return topSubjectOf(this.document.uri)
|
||||
}
|
||||
|
||||
/** 把光标放进顶部输入框;页面还没准备好就等 ready。 */
|
||||
@@ -526,7 +531,10 @@ class Preview {
|
||||
private async ask(askId: string, anchor: string, question: string, images: ClaudeImage[]): Promise<void> {
|
||||
const subject = this.subject()
|
||||
if (anchor === PATH_ASK_ANCHOR) {
|
||||
const refused = await this.owner.qa.askPath(subject.uri, subject.kind, { askId, question, images })
|
||||
const request = { askId, question, images }
|
||||
const refused = subject.kind === 'feature'
|
||||
? await this.owner.askFeature(subject.uri, request)
|
||||
: await this.owner.qa.askPath(subject.uri, subject.kind, request)
|
||||
if (refused !== undefined)
|
||||
this.post({ type: 'answerEnd', askId, status: 'failed', error: refused })
|
||||
else
|
||||
@@ -808,7 +816,7 @@ class Preview {
|
||||
const root = await repoRootOf(this.document.uri.fsPath)
|
||||
const asked = new Map<string, Promise<SymbolNode[]>>()
|
||||
const symbolsOf = (uri: string): Promise<SymbolNode[]> => {
|
||||
const known = asked.get(uri) ?? this.symbolNodes(uri)
|
||||
const known = asked.get(uri) ?? documentSymbolNodes(uri)
|
||||
asked.set(uri, known)
|
||||
return known
|
||||
}
|
||||
@@ -919,29 +927,15 @@ class Preview {
|
||||
|
||||
/** 用文档符号按名字找(`Agent`、`Agent.owner_id`);没有这个名字就没有,不打开到文件顶上。 */
|
||||
private async locateRepoSymbol(uri: Uri, symbol: string): Promise<LinkTarget | undefined> {
|
||||
const found = findRepoSymbol(await this.symbolNodes(uri.toString()), symbol)
|
||||
const found = findRepoSymbol(await documentSymbolNodes(uri.toString()), symbol)
|
||||
return found && { uri: uri.toString(), selection: found.selection }
|
||||
}
|
||||
|
||||
/** 一个文件的文档符号(vscode.executeDocumentSymbolProvider):先把文件读进来(不显示),范围换成那个文件里的下标。 */
|
||||
private async symbolNodes(uri: string): Promise<SymbolNode[]> {
|
||||
const doc = await workspace.openTextDocument(Uri.parse(uri))
|
||||
const symbols = await commands.executeCommand<DocumentSymbol[] | undefined>('vscode.executeDocumentSymbolProvider', doc.uri)
|
||||
const span = (range: Range): SourceRange => ({ startIndex: doc.offsetAt(range.start), endIndex: doc.offsetAt(range.end) })
|
||||
const node = (symbol: DocumentSymbol): SymbolNode => ({
|
||||
name: symbol.name,
|
||||
range: span(symbol.range),
|
||||
selection: span(symbol.selectionRange),
|
||||
children: (symbol.children ?? []).map(node),
|
||||
})
|
||||
return Array.isArray(symbols) ? symbols.map(node) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* 注释里不带路径的引用(`[Name]`、`[Class.member]`)指到哪:本文件定义的优先,再是导入进来的(见 symbolLinks.ts)。
|
||||
* 文档符号同仓库链接找符号那一套(symbols,一批引用一起问时共用一份),导入的名字问语言扩展它的定义(同跳到定义),定义几处取第一处。
|
||||
*/
|
||||
private async resolveSymbolLink(symbol: string, symbols: (uri: string) => Promise<SymbolNode[]> = uri => this.symbolNodes(uri)): Promise<LinkTarget | undefined> {
|
||||
private async resolveSymbolLink(symbol: string, symbols: (uri: string) => Promise<SymbolNode[]> = documentSymbolNodes): Promise<LinkTarget | undefined> {
|
||||
const { document } = this
|
||||
try {
|
||||
return await resolveReference(symbol, this.imports, {
|
||||
|
||||
@@ -56,6 +56,19 @@ describe('findRepoSymbol', () => {
|
||||
expect(findRepoSymbol(tree, 'Agent.missing')).toBeUndefined()
|
||||
expect(findRepoSymbol(tree, 'Missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('几层点开的一层层往里找;有一段是空的不算', () => {
|
||||
expect(findRepoSymbol(tree, 'Agent.Nested.inner')?.name).toBe('inner')
|
||||
expect(findRepoSymbol(tree, 'Agent.owner_id.inner')).toBeUndefined()
|
||||
expect(findRepoSymbol(tree, 'Agent..inner')).toBeUndefined()
|
||||
expect(findRepoSymbol(tree, 'Agent.')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('同名的有几个时挨个试:第一个里面没有就找下一个', () => {
|
||||
const rust = [{ name: 'Order' }, { name: 'Order', children: [{ name: 'pay' }] }]
|
||||
expect(findRepoSymbol(rust, 'Order.pay')).toBe(rust[1].children?.[0])
|
||||
expect(findRepoSymbol(rust, 'Order')).toBe(rust[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRepoEntry', () => {
|
||||
|
||||
@@ -88,32 +88,33 @@ export async function resolveRepoEntry(root: string, rel: string): Promise<RepoE
|
||||
}
|
||||
|
||||
/**
|
||||
* 在文档符号里按名字找。`Agent` 找这个类或函数;`Agent.owner_id` 先找 `Agent`,再在它里面找 `owner_id`。
|
||||
* 名字对的是符号自己的 name,不看源码怎么写。
|
||||
* 在符号树里按名字找。`Agent` 找这个类或函数;`Agent.owner_id` 先找 `Agent`,再在它里面找 `owner_id`,点得更多的一层层往里找。
|
||||
* 同名的有几个时(Rust 的 `struct Order` 和 `impl Order`)挨个试,取第一个里面找得到后面几段的。
|
||||
* 名字对的是符号自己的 name,不看源码怎么写;有一段是空的(`Agent.`、`a..b`)找不到。
|
||||
*/
|
||||
export function findRepoSymbol<T extends NamedSymbol>(symbols: readonly T[], symbol: string): T | undefined {
|
||||
const dot = symbol.indexOf('.')
|
||||
const head = dot === -1 ? symbol : symbol.slice(0, dot)
|
||||
const member = dot === -1 ? undefined : symbol.slice(dot + 1)
|
||||
if (head === '' || member === '')
|
||||
return undefined
|
||||
const found = findNamed(symbols, head)
|
||||
if (!found || member === undefined)
|
||||
return found
|
||||
return findNamed((found.children ?? []) as readonly T[], member)
|
||||
const names = symbol.split('.')
|
||||
return names.includes('') ? undefined : findPath(symbols, names)
|
||||
}
|
||||
|
||||
function findNamed<T extends NamedSymbol>(symbols: readonly T[], name: string): T | undefined {
|
||||
for (const symbol of symbols) {
|
||||
if (symbol.name === name)
|
||||
return symbol
|
||||
const nested = findNamed((symbol.children ?? []) as readonly T[], name)
|
||||
if (nested)
|
||||
return nested
|
||||
/** 第一段按名字找(照先后、先往里再往后),每个同名的都在它里面接着找后面几段。 */
|
||||
function findPath<T extends NamedSymbol>(symbols: readonly T[], [name, ...rest]: readonly string[]): T | undefined {
|
||||
for (const found of named(symbols, name)) {
|
||||
const target = rest.length === 0 ? found : findPath((found.children ?? []) as readonly T[], rest)
|
||||
if (target)
|
||||
return target
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function* named<T extends NamedSymbol>(symbols: readonly T[], name: string): Generator<T> {
|
||||
for (const symbol of symbols) {
|
||||
if (symbol.name === name)
|
||||
yield symbol
|
||||
yield* named((symbol.children ?? []) as readonly T[], name)
|
||||
}
|
||||
}
|
||||
|
||||
function hasControl(text: string): boolean {
|
||||
for (const char of text) {
|
||||
if (char.charCodeAt(0) <= 31)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FAVORITE_SLOTS, favoriteCommand, favoriteContext, favoriteViewId } from '../favorites/favoriteSlots'
|
||||
import { favoriteCommand } from '../favorites/favoriteSlots'
|
||||
import { FEATURE_MARKER, featureCommand } from '../features/features'
|
||||
import { slotContext, SLOTS, slotViewId } from '../slots/slots'
|
||||
import { UNSTAGED_MARKER } from '../uncommitted/uncommittedFiles'
|
||||
import { COPY_COMMANDS } from './copyText'
|
||||
import { entryContext, GROUP_CONTEXT } from './fileEntries'
|
||||
@@ -95,8 +97,9 @@ function evaluate(when: string, context: Context): boolean {
|
||||
|
||||
const FILES = 'superpowers.codeReader.files'
|
||||
const UNCOMMITTED = 'superpowers.codeReader.uncommitted'
|
||||
const FAVORITE = favoriteViewId(7)
|
||||
const READER_VIEWS = [FILES, UNCOMMITTED, ...FAVORITE_SLOTS.map(favoriteViewId)]
|
||||
const FAVORITE = slotViewId(7)
|
||||
const FEATURE = slotViewId(8)
|
||||
const READER_VIEWS = [FILES, UNCOMMITTED, ...SLOTS.map(slotViewId)]
|
||||
const REVIEWING = 'superpowers.codeReader.reviewing'
|
||||
|
||||
const COPY_MENU = 'superpowers.codeReader.copy'
|
||||
@@ -277,6 +280,7 @@ describe('右键菜单的「在系统文件管理器中显示」', () => {
|
||||
})
|
||||
|
||||
describe('主栏的审查工单', () => {
|
||||
const FIND = 'superpowers.codeReader.findFeature'
|
||||
const PICK = 'superpowers.codeReader.pickReviewIssue'
|
||||
const HIDE_TESTS = 'superpowers.codeReader.hideReviewTests'
|
||||
const SHOW_TESTS = 'superpowers.codeReader.showReviewTests'
|
||||
@@ -284,10 +288,10 @@ describe('主栏的审查工单', () => {
|
||||
const REFRESH = 'superpowers.codeReader.refresh'
|
||||
const TESTS_HIDDEN = 'superpowers.codeReader.reviewTestsHidden'
|
||||
|
||||
it('标题栏:平时是选工单、刷新;审查时多出隐藏 / 显示测试文件(按开关换着显示)和「显示全部」', () => {
|
||||
expect(titleBar(FILES)).toEqual([PICK, REFRESH])
|
||||
expect(titleBar(FILES, { [REVIEWING]: true })).toEqual([PICK, HIDE_TESTS, SHOW_ALL, REFRESH])
|
||||
expect(titleBar(FILES, { [REVIEWING]: true, [TESTS_HIDDEN]: true })).toEqual([PICK, SHOW_TESTS, SHOW_ALL, REFRESH])
|
||||
it('标题栏:平时是了解一块代码、选工单、刷新;审查时多出隐藏 / 显示测试文件(按开关换着显示)和「显示全部」', () => {
|
||||
expect(titleBar(FILES)).toEqual([FIND, PICK, REFRESH])
|
||||
expect(titleBar(FILES, { [REVIEWING]: true })).toEqual([FIND, PICK, HIDE_TESTS, SHOW_ALL, REFRESH])
|
||||
expect(titleBar(FILES, { [REVIEWING]: true, [TESTS_HIDDEN]: true })).toEqual([FIND, PICK, SHOW_TESTS, SHOW_ALL, REFRESH])
|
||||
expect(menus['view/title'].filter(item => item.when.includes(FILES)).every(item => item.group?.startsWith('navigation'))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -347,22 +351,34 @@ describe('「未提交的更改」一栏', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('收藏栏', () => {
|
||||
it(`预先声明 ${FAVORITE_SLOTS.length} 个栏位,排在「未提交的更改」后面,各自有收藏占着时才显示`, () => {
|
||||
expect(views['superpowers-reader'].slice(2)).toEqual(FAVORITE_SLOTS.map(slot => ({ id: favoriteViewId(slot), name: `收藏 ${slot}`, when: favoriteContext(slot) })))
|
||||
describe('收藏、「了解一块代码」共用的栏位', () => {
|
||||
it(`预先声明 ${SLOTS.length} 个栏位,排在「未提交的更改」后面,各自有收藏或功能占着时才显示`, () => {
|
||||
expect(views['superpowers-reader'].slice(2)).toEqual(SLOTS.map(slot => ({ id: slotViewId(slot), name: `栏位 ${slot}`, when: slotContext(slot) })))
|
||||
})
|
||||
|
||||
it('每一栏的标题栏:重命名、取消收藏,只对这一栏', () => {
|
||||
for (const slot of FAVORITE_SLOTS)
|
||||
expect(titleBar(favoriteViewId(slot))).toEqual([favoriteCommand('rename', slot), favoriteCommand('remove', slot)])
|
||||
it('收藏占着的栏:标题栏是重命名、取消收藏,只对这一栏', () => {
|
||||
for (const slot of SLOTS)
|
||||
expect(titleBar(slotViewId(slot), { [slotContext(slot)]: 'favorite' })).toEqual([favoriteCommand('rename', slot), favoriteCommand('remove', slot)])
|
||||
})
|
||||
|
||||
it('每个栏位的命令都登记了、字和图标一样,命令面板里藏起来', () => {
|
||||
for (const slot of FAVORITE_SLOTS) {
|
||||
it('功能占着的栏:标题栏是打开对话、重命名、删除,只对这一栏', () => {
|
||||
for (const slot of SLOTS)
|
||||
expect(titleBar(slotViewId(slot), { [slotContext(slot)]: 'feature' })).toEqual([featureCommand('openChat', slot), featureCommand('rename', slot), featureCommand('remove', slot)])
|
||||
})
|
||||
|
||||
it('别的栏被占着不影响这一栏的按钮', () => {
|
||||
expect(titleBar(slotViewId(3), { [slotContext(4)]: 'feature' })).toEqual([])
|
||||
})
|
||||
|
||||
it('每个栏位的命令都登记了、字和图标,命令面板里藏起来', () => {
|
||||
for (const slot of SLOTS) {
|
||||
expect(titleOf(favoriteCommand('rename', slot))).toMatchObject({ title: '重命名', icon: '$(edit)' })
|
||||
expect(titleOf(favoriteCommand('remove', slot))).toMatchObject({ title: '取消收藏', icon: '$(star-full)' })
|
||||
expect(palette.get(favoriteCommand('rename', slot))).toBe('false')
|
||||
expect(palette.get(favoriteCommand('remove', slot))).toBe('false')
|
||||
expect(titleOf(featureCommand('openChat', slot))).toMatchObject({ title: '打开对话', icon: '$(comment-discussion)' })
|
||||
expect(titleOf(featureCommand('rename', slot))).toMatchObject({ title: '重命名', icon: '$(edit)' })
|
||||
expect(titleOf(featureCommand('remove', slot))).toMatchObject({ title: '删除', icon: '$(trash)' })
|
||||
for (const id of [favoriteCommand('rename', slot), favoriteCommand('remove', slot), featureCommand('openChat', slot), featureCommand('rename', slot), featureCommand('remove', slot)])
|
||||
expect(palette.get(id)).toBe('false')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -378,6 +394,43 @@ describe('收藏栏', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('了解一块代码', () => {
|
||||
const FIND = 'superpowers.codeReader.findFeature'
|
||||
const OPEN_ITEM = 'superpowers.codeReader.openFeatureItem'
|
||||
/** 功能栏里找得到的条目行(符号、只有文件的都是它) */
|
||||
const item = entryContext({ isDirectory: false, isRoot: false }, FEATURE_MARKER)
|
||||
|
||||
it('入口只在主栏标题栏:放大镜「了解一块代码」,排在最前;别的栏没有', () => {
|
||||
expect(titleBar(FILES)[0]).toBe(FIND)
|
||||
expect(titleBar(FILES, { [REVIEWING]: true })[0]).toBe(FIND)
|
||||
for (const view of [UNCOMMITTED, FEATURE])
|
||||
expect(titleBar(view, { [slotContext(8)]: 'feature' })).not.toContain(FIND)
|
||||
expect(titleOf(FIND)).toMatchObject({ title: '了解一块代码', icon: '$(search)' })
|
||||
})
|
||||
|
||||
it('条目行:「问 AI」「复制」、在系统文件管理器中显示照常有;注释、收藏、暂存不挂', () => {
|
||||
expect(itemsFor('view/item/context', 'inline', item, FEATURE)).toEqual([ASK, COPY_MENU])
|
||||
expect(itemsFor('view/item/context', 'copy', item, FEATURE)).toEqual(FILE_COPIES)
|
||||
expect(itemsFor(COPY_MENU, 'copy', item, FEATURE)).toEqual(FILE_COPIES)
|
||||
expect(itemsFor('view/item/context', 'fileManager', item, FEATURE)).toEqual(['superpowers.codeReader.revealInFinder'])
|
||||
for (const group of ['notes', 'star'])
|
||||
expect(itemsFor('view/item/context', group, item, FEATURE)).toEqual([])
|
||||
})
|
||||
|
||||
it('分组行、找不到的条目行什么按钮、菜单都没有', () => {
|
||||
for (const context of [GROUP_CONTEXT, `${FEATURE_MARKER} missing`]) {
|
||||
for (const group of ['inline', 'copy', 'fileManager', 'notes', 'star'])
|
||||
expect(itemsFor('view/item/context', group, context, FEATURE)).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('点条目的命令只给侧栏行用,命令面板里藏起来;「了解一块代码」命令面板里能用', () => {
|
||||
expect(titleOf(OPEN_ITEM)).toBeDefined()
|
||||
expect(palette.get(OPEN_ITEM)).toBe('false')
|
||||
expect(palette.has(FIND)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('菜单里用到的', () => {
|
||||
it('命令都登记了;旧的筛选按钮、上下文键不再出现', () => {
|
||||
const ids = new Set(commands.map(item => item.command))
|
||||
|
||||
@@ -1,40 +1,48 @@
|
||||
import type { Favorite } from './favoriteSlots'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FAVORITE_SLOTS, favoriteCommand, favoriteContext, favoriteViewId, placeFavorite, removeFavorite, renameFavorite } from './favoriteSlots'
|
||||
import { SLOTS } from '../slots/slots'
|
||||
import { favoriteCommand, placeFavorite, removeFavorite, renameFavorite } from './favoriteSlots'
|
||||
|
||||
const favorite = (slot: number, name = `收藏${slot}`): Favorite => ({ uri: `file:///ws/dir${slot}`, name, slot })
|
||||
|
||||
/** 栏位 1…count 全占着 */
|
||||
const filled = (count: number): Favorite[] => Array.from({ length: count }, (_, index) => favorite(index + 1))
|
||||
|
||||
const slotsOf = (favorites: readonly Favorite[]): number[] => favorites.map(item => item.slot)
|
||||
|
||||
describe('新收藏占哪个栏位', () => {
|
||||
it('没有收藏时占第 1 个', () => {
|
||||
expect(placeFavorite([], 'file:///ws/a')).toEqual({ slot: 1 })
|
||||
expect(placeFavorite([], 'file:///ws/a', [])).toEqual({ slot: 1 })
|
||||
})
|
||||
|
||||
it('占没被占的里面最小的:中间空出来的先用', () => {
|
||||
expect(placeFavorite([favorite(1), favorite(2)], 'file:///ws/a')).toEqual({ slot: 3 })
|
||||
expect(placeFavorite([favorite(1), favorite(3), favorite(4)], 'file:///ws/a')).toEqual({ slot: 2 })
|
||||
expect(placeFavorite([favorite(5)], 'file:///ws/a')).toEqual({ slot: 1 })
|
||||
expect(placeFavorite([favorite(1), favorite(2)], 'file:///ws/a', [1, 2])).toEqual({ slot: 3 })
|
||||
expect(placeFavorite([favorite(1), favorite(3), favorite(4)], 'file:///ws/a', [1, 3, 4])).toEqual({ slot: 2 })
|
||||
})
|
||||
|
||||
it('和「了解一块代码」共用栏位:功能占着的跳过,功能删掉空出来的收藏照样能用', () => {
|
||||
const favorites = [favorite(1)]
|
||||
expect(placeFavorite(favorites, 'file:///ws/a', [1, 2, 3])).toEqual({ slot: 4 })
|
||||
expect(placeFavorite(favorites, 'file:///ws/a', [1, 3])).toEqual({ slot: 2 })
|
||||
})
|
||||
|
||||
it('取消收藏后空出来的栏位给下一个收藏用,别的收藏不挪位置', () => {
|
||||
const left = removeFavorite([favorite(1), favorite(2), favorite(3)], 2)
|
||||
|
||||
expect(left).toEqual([favorite(1), favorite(3)])
|
||||
expect(placeFavorite(left, 'file:///ws/a')).toEqual({ slot: 2 })
|
||||
expect(placeFavorite(left, 'file:///ws/a', slotsOf(left))).toEqual({ slot: 2 })
|
||||
})
|
||||
|
||||
it(`${FAVORITE_SLOTS.length} 个栏位都占满了:满了`, () => {
|
||||
expect(FAVORITE_SLOTS).toEqual(Array.from({ length: 30 }, (_, index) => index + 1))
|
||||
expect(placeFavorite(filled(FAVORITE_SLOTS.length), 'file:///ws/a')).toBe('full')
|
||||
expect(placeFavorite(filled(FAVORITE_SLOTS.length - 1), 'file:///ws/a')).toEqual({ slot: FAVORITE_SLOTS.length })
|
||||
it('栏位都被收藏和功能占满了:满了', () => {
|
||||
const favorites = filled(10)
|
||||
expect(placeFavorite(favorites, 'file:///ws/a', SLOTS)).toBe('full')
|
||||
expect(placeFavorite(favorites, 'file:///ws/a', SLOTS.slice(0, -1))).toEqual({ slot: SLOTS.length })
|
||||
})
|
||||
|
||||
it('同一个目录收藏过:给出原来那个,不再占栏位;满了也先认出收藏过', () => {
|
||||
const favorites = [favorite(1), favorite(2)]
|
||||
expect(placeFavorite(favorites, 'file:///ws/dir2')).toEqual({ existing: favorite(2) })
|
||||
expect(placeFavorite(filled(FAVORITE_SLOTS.length), 'file:///ws/dir7')).toEqual({ existing: favorite(7) })
|
||||
expect(placeFavorite(favorites, 'file:///ws/dir2', [1, 2])).toEqual({ existing: favorite(2) })
|
||||
expect(placeFavorite(filled(SLOTS.length), 'file:///ws/dir7', SLOTS)).toEqual({ existing: favorite(7) })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,10 +57,8 @@ describe('重命名、取消收藏', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('栏位对应的视图、上下文键、命令', () => {
|
||||
it('第 n 个栏位各有自己的视图 id、显示不显示的上下文键、标题栏上重命名 / 取消收藏的命令', () => {
|
||||
expect(favoriteViewId(3)).toBe('superpowers.codeReader.favorite3')
|
||||
expect(favoriteContext(3)).toBe('superpowers.codeReader.hasFavorite3')
|
||||
describe('收藏栏标题栏上的命令', () => {
|
||||
it('每个栏位各有重命名 / 取消收藏的命令', () => {
|
||||
expect(favoriteCommand('rename', 3)).toBe('superpowers.codeReader.renameFavorite3')
|
||||
expect(favoriteCommand('remove', 30)).toBe('superpowers.codeReader.removeFavorite30')
|
||||
})
|
||||
|
||||
@@ -1,40 +1,26 @@
|
||||
/**
|
||||
* 收藏栏的栏位:VS Code 的视图要在 package.json 里预先声明,所以声明了固定这么多个收藏栏(superpowers.codeReader.favorite1 … favorite30),
|
||||
* 有收藏占着的才显示。这里是栏位号 1、2、3…
|
||||
*/
|
||||
export const FAVORITE_SLOTS: readonly number[] = Array.from({ length: 30 }, (_, index) => index + 1)
|
||||
import { freeSlot } from '../slots/slots'
|
||||
|
||||
/** 一个收藏:目录(Uri 字符串)、栏标题上的名字、占着第几个栏位。 */
|
||||
/** 一个收藏:目录(Uri 字符串)、栏标题上的名字、占着第几个栏位(和「了解一块代码」共用,见 slots.ts)。 */
|
||||
export interface Favorite {
|
||||
uri: string
|
||||
name: string
|
||||
slot: number
|
||||
}
|
||||
|
||||
/** 第 slot 个收藏栏的视图 id */
|
||||
export function favoriteViewId(slot: number): string {
|
||||
return `superpowers.codeReader.favorite${slot}`
|
||||
}
|
||||
|
||||
/** 上下文键:第 slot 个栏位有收藏占着,这一栏才显示(package.json 里视图的 when 按它) */
|
||||
export function favoriteContext(slot: number): string {
|
||||
return `superpowers.codeReader.hasFavorite${slot}`
|
||||
}
|
||||
|
||||
/** 第 slot 个收藏栏标题栏上的按钮命令:rename 重命名、remove 取消收藏。标题栏按钮不知道点的是哪一栏,所以每个栏位各一条。 */
|
||||
export function favoriteCommand(action: 'rename' | 'remove', slot: number): string {
|
||||
return `superpowers.codeReader.${action}Favorite${slot}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏 uri 这个目录要占哪个栏位:收藏过的给出原来那个(不重复收藏),栏位都占满了是 full,别的占没被占的里面最小的。
|
||||
* 一个收藏一直占着它的栏位、删掉才空出来,用户拖动排过的位置 VS Code 按栏位记着,所以不会错乱。
|
||||
* 收藏 uri 这个目录要占哪个栏位:收藏过的给出原来那个(不重复收藏),栏位都被收藏和功能占满了是 full,别的占空着的里面最小的。
|
||||
* taken 是收藏、功能眼下占着的全部栏位。
|
||||
*/
|
||||
export function placeFavorite(favorites: readonly Favorite[], uri: string): { existing: Favorite } | { slot: number } | 'full' {
|
||||
export function placeFavorite(favorites: readonly Favorite[], uri: string, taken: Iterable<number>): { existing: Favorite } | { slot: number } | 'full' {
|
||||
const existing = favorites.find(favorite => favorite.uri === uri)
|
||||
if (existing)
|
||||
return { existing }
|
||||
const slot = FAVORITE_SLOTS.find(slot => !favorites.some(favorite => favorite.slot === slot))
|
||||
const slot = freeSlot(taken)
|
||||
return slot === undefined ? 'full' : { slot }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,18 @@
|
||||
import type { Memento, TreeView } from 'vscode'
|
||||
import type { FileNode, FileTreeDecorator, TreeNode } from '../explorer/fileTree'
|
||||
import type { SlotPool } from '../slots/slotPool'
|
||||
import type { Favorite } from './favoriteSlots'
|
||||
import * as path from 'node:path'
|
||||
import { commands, FileType, Uri, window, workspace } from 'vscode'
|
||||
import { FileType, Uri, window, workspace } from 'vscode'
|
||||
import { FileTreeProvider } from '../explorer/fileTree'
|
||||
import { TreeLocator } from '../explorer/treeLocator'
|
||||
import { FAVORITE_SLOTS, favoriteContext, favoriteViewId, placeFavorite, removeFavorite, renameFavorite } from './favoriteSlots'
|
||||
import { askSlotName, focusSlot } from '../slots/slotPool'
|
||||
import { SLOTS, slotTitle, slotViewId } from '../slots/slots'
|
||||
import { placeFavorite, removeFavorite, renameFavorite } from './favoriteSlots'
|
||||
|
||||
/** workspaceState:收藏的目录(Favorite 的数组),按工作区记。 */
|
||||
const FAVORITES_KEY = 'superpowers.codeReader.favorites'
|
||||
|
||||
/** 弹顶部的输入框问收藏栏叫什么(原值整段选中);Esc 或点别处是不改。 */
|
||||
async function askName(title: string, value: string): Promise<string | undefined> {
|
||||
const name = await window.showInputBox({
|
||||
title,
|
||||
value,
|
||||
prompt: '收藏栏标题上显示的名字',
|
||||
validateInput: text => text.trim() ? undefined : '名字不能为空',
|
||||
})
|
||||
return name?.trim()
|
||||
}
|
||||
|
||||
/** 亮出第 slot 个收藏栏(VS Code 给每个视图自带一条 `<视图 id>.focus` 命令)。 */
|
||||
async function focus(slot: number): Promise<void> {
|
||||
await commands.executeCommand(`${favoriteViewId(slot)}.focus`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个收藏栏:这个目录下的完整文件树(自己的文件树和视图),标题是起的名字、后面淡色写目录相对工作区的路径;
|
||||
* 当前文件在这个目录下、这一栏露着时跟过去定位。目录被删了时栏里说一句目录不在了。收藏时建、取消收藏时销毁。
|
||||
@@ -40,8 +27,8 @@ class FavoritePane {
|
||||
constructor(favorite: Favorite, openCommand: string, decorator: FileTreeDecorator) {
|
||||
this.uri = Uri.parse(favorite.uri)
|
||||
const files = new FileTreeProvider(openCommand, decorator, { kind: 'directory', uri: this.uri })
|
||||
this.view = window.createTreeView(favoriteViewId(favorite.slot), { treeDataProvider: files, showCollapseAll: true })
|
||||
this.view.title = favorite.name
|
||||
this.view = window.createTreeView(slotViewId(favorite.slot), { treeDataProvider: files, showCollapseAll: true })
|
||||
this.view.title = slotTitle('favorite', favorite.name)
|
||||
this.view.description = workspace.asRelativePath(this.uri)
|
||||
this.disposables = [
|
||||
files,
|
||||
@@ -57,7 +44,7 @@ class FavoritePane {
|
||||
}
|
||||
|
||||
rename(name: string): void {
|
||||
this.view.title = name
|
||||
this.view.title = slotTitle('favorite', name)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -74,7 +61,7 @@ class FavoritePane {
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏的收藏栏:收藏一个目录占一个栏位,栏里是这个目录下的完整文件树,规则同主栏的工作区文件树、不受审查影响(栏位怎么分见 placeFavorite)。
|
||||
* 侧栏的收藏栏:收藏一个目录占一个栏位(和「了解一块代码」共用,见 SlotPool),栏里是这个目录下的完整文件树,规则同主栏的工作区文件树、不受审查影响(栏位怎么分见 placeFavorite)。
|
||||
* 只给有收藏占着的栏位建视图,上下文键让这一栏显示出来;收藏按工作区记住,打开窗口时照记着的建回来。
|
||||
*/
|
||||
export class FavoriteViews {
|
||||
@@ -84,14 +71,16 @@ export class FavoriteViews {
|
||||
|
||||
/**
|
||||
* @param state workspaceState
|
||||
* @param pool 和「了解一块代码」共用的栏位
|
||||
* @param openCommand 点文件时执行的命令,参数是文件 Uri
|
||||
* @param decorator 给行添信息(文件注释),和别的栏共用
|
||||
*/
|
||||
constructor(private readonly state: Memento, private readonly openCommand: string, private readonly decorator: FileTreeDecorator) {
|
||||
constructor(private readonly state: Memento, private readonly pool: SlotPool, private readonly openCommand: string, private readonly decorator: FileTreeDecorator) {
|
||||
this.favorites = state.get<Favorite[]>(FAVORITES_KEY) ?? []
|
||||
for (const favorite of this.favorites)
|
||||
for (const favorite of this.favorites) {
|
||||
this.open(favorite)
|
||||
void Promise.all(this.favorites.map(favorite => commands.executeCommand('setContext', favoriteContext(favorite.slot), true)))
|
||||
void pool.occupy(favorite.slot, 'favorite')
|
||||
}
|
||||
}
|
||||
|
||||
/** 目录行上的「收藏目录」:起个名字(默认是目录名)占一个空栏位并亮出那一栏;收藏过的说一声、亮出原来那一栏,满了说一声。 */
|
||||
@@ -99,26 +88,23 @@ export class FavoriteViews {
|
||||
if (!node)
|
||||
return // 只挂在侧栏的目录行上,别处调用没有目标
|
||||
const uri = node.uri.toString()
|
||||
const placement = placeFavorite(this.favorites, uri)
|
||||
const placement = placeFavorite(this.favorites, uri, this.pool.taken())
|
||||
if (placement === 'full') {
|
||||
void window.showWarningMessage(`收藏栏满了(最多 ${FAVORITE_SLOTS.length} 个),先取消一个再收藏`)
|
||||
void window.showWarningMessage(`栏位满了(收藏和「了解一块代码」共用,最多 ${SLOTS.length} 个),先取消一个收藏或删掉一个功能再收藏`)
|
||||
return
|
||||
}
|
||||
if ('existing' in placement) {
|
||||
void window.showInformationMessage(`这个目录已经收藏过了:「${placement.existing.name}」`)
|
||||
await focus(placement.existing.slot)
|
||||
await focusSlot(placement.existing.slot)
|
||||
return
|
||||
}
|
||||
const name = await askName('收藏目录', path.posix.basename(node.uri.path))
|
||||
const name = await askSlotName('收藏目录', path.posix.basename(node.uri.path))
|
||||
if (name === undefined)
|
||||
return
|
||||
const favorite: Favorite = { uri, name, slot: placement.slot }
|
||||
this.open(favorite)
|
||||
await Promise.all([
|
||||
this.save([...this.favorites, favorite]),
|
||||
commands.executeCommand('setContext', favoriteContext(favorite.slot), true),
|
||||
])
|
||||
await focus(favorite.slot)
|
||||
await Promise.all([this.save([...this.favorites, favorite]), this.pool.occupy(favorite.slot, 'favorite')])
|
||||
await focusSlot(favorite.slot)
|
||||
}
|
||||
|
||||
/** 收藏栏标题栏的「重命名」:改第 slot 栏的标题。 */
|
||||
@@ -126,7 +112,7 @@ export class FavoriteViews {
|
||||
const favorite = this.favorites.find(item => item.slot === slot)
|
||||
if (!favorite)
|
||||
return
|
||||
const name = await askName('重命名收藏', favorite.name)
|
||||
const name = await askSlotName('重命名收藏', favorite.name)
|
||||
if (name === undefined)
|
||||
return
|
||||
this.panes.get(slot)?.rename(name)
|
||||
@@ -135,12 +121,11 @@ export class FavoriteViews {
|
||||
|
||||
/** 收藏栏标题栏的「取消收藏」:藏起第 slot 栏、空出栏位,再销毁它的视图(免得露着的栏里闪一下没有数据)。 */
|
||||
async remove(slot: number): Promise<void> {
|
||||
if (!this.panes.has(slot))
|
||||
return
|
||||
const pane = this.panes.get(slot)
|
||||
this.panes.delete(slot)
|
||||
await Promise.all([
|
||||
this.save(removeFavorite(this.favorites, slot)),
|
||||
commands.executeCommand('setContext', favoriteContext(slot), false),
|
||||
])
|
||||
await Promise.all([this.save(removeFavorite(this.favorites, slot)), this.pool.release(slot)])
|
||||
pane?.dispose()
|
||||
}
|
||||
|
||||
|
||||
@@ -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('还没有结果,可以在对话里接着说,让它再找一次。')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ExtensionContext, Uri } from 'vscode'
|
||||
import type { CodeReaderApi, RuleLocation, RuleReference } from './contract'
|
||||
import type { TargetSpot } from './editor/openTarget'
|
||||
import type { FileNode } from './explorer/fileTree'
|
||||
import type { FeatureRow } from './features/featureViews'
|
||||
import type { IndexedName } from './nameResolution'
|
||||
import { commands, window, workspace } from 'vscode'
|
||||
import { logger } from '../logging/logger'
|
||||
@@ -21,8 +23,10 @@ import { registerPathAsk } from './explorer/pathAskPanel'
|
||||
import { registerRevealCommands } from './explorer/revealCommands'
|
||||
import { RowDecorator } from './explorer/rowDecorator'
|
||||
import { TreeLocator } from './explorer/treeLocator'
|
||||
import { FAVORITE_SLOTS, favoriteCommand } from './favorites/favoriteSlots'
|
||||
import { favoriteCommand } from './favorites/favoriteSlots'
|
||||
import { FavoriteViews } from './favorites/favoriteViews'
|
||||
import { featureCommand } from './features/features'
|
||||
import { FeatureViews, locateItem, OPEN_FEATURE_ITEM } from './features/featureViews'
|
||||
import { createBashAdapter } from './languages/bash'
|
||||
import { createDockerfileAdapter } from './languages/dockerfile'
|
||||
import { createPythonAdapter } from './languages/python'
|
||||
@@ -49,6 +53,8 @@ import { sqlxRule } from './rules/sqlx'
|
||||
import { tauriRule } from './rules/tauri'
|
||||
import { thiserrorRule } from './rules/thiserror'
|
||||
import { bundledWasmDir, indexGrammar, TreeSitterRuntime } from './runtime'
|
||||
import { SlotPool } from './slots/slotPool'
|
||||
import { SLOTS } from './slots/slots'
|
||||
import { bashZhTranslator } from './translators/bashZh'
|
||||
import { dockerfileZhTranslator } from './translators/dockerfileZh'
|
||||
import { pythonZhTranslator } from './translators/pythonZh'
|
||||
@@ -64,18 +70,26 @@ const LANGUAGE_IDS_CONTEXT = 'superpowers.codeReader.languageIds'
|
||||
const FILES_VIEW = 'superpowers.codeReader.files'
|
||||
const OPEN_COMMAND = 'superpowers.codeReader.open'
|
||||
|
||||
/** 侧栏点文件:语言受支持就用预览打开,否则用 VS Code 默认的打开方式(见 openingFor)。 */
|
||||
async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
|
||||
const opening = openingFor(await languageOf(uri), languageId => registry.languageFor(languageId) !== undefined)
|
||||
if (opening.kind === 'preview')
|
||||
// 和 vscode.open 一样按「预览标签」打开(用户关了 workbench.editor.enablePreview 时 VS Code 自会固定)
|
||||
await commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true })
|
||||
/**
|
||||
* 侧栏点文件、点「了解一块代码」栏里的一条:语言受支持就用预览打开,否则用 VS Code 默认的打开方式(见 openingFor)。
|
||||
* 给了 spot(条目的符号在哪)就跳过去:预览滚到那一块、闪一下,普通编辑器选中那个名字。
|
||||
*/
|
||||
async function openFile(uri: Uri, registry: CodeReaderRegistry, previews: PreviewEditorProvider, spot?: TargetSpot): Promise<void> {
|
||||
const opening = openingFor(await languageOf(uri), languageId => registry.languageFor(languageId) !== undefined, spot)
|
||||
if (opening.kind === 'default') {
|
||||
await commands.executeCommand('vscode.open', uri, opening.selection && { selection: opening.selection })
|
||||
return
|
||||
}
|
||||
// 和 vscode.open 一样按「预览标签」打开(用户关了 workbench.editor.enablePreview 时 VS Code 自会固定)
|
||||
const open = (): Thenable<unknown> => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true })
|
||||
if (opening.reveal)
|
||||
await previews.openAt(uri, opening.reveal, open)
|
||||
else
|
||||
await commands.executeCommand('vscode.open', uri)
|
||||
await open()
|
||||
}
|
||||
|
||||
/**
|
||||
* 接入代码阅读:注册表、侧栏的几栏(主栏的文件树可以按审查工单只看 PR 改过的文件,常驻的「未提交的更改」,每个收藏目录一栏;
|
||||
* 接入代码阅读:注册表、侧栏的几栏(主栏的文件树可以按审查工单只看 PR 改过的文件,常驻的「未提交的更改」,每个收藏目录、每个「了解一块代码」找的功能各一栏;
|
||||
* 行上有问路径、复制、在系统文件管理器里显示、文件注释、收藏目录)、预览编辑器(含问 AI,按文件所在位置标差异)、编辑器右上角的「预览 / 编辑」切换。
|
||||
* 内置的语言插件、翻译插件、库规则和命令规则与其他扩展一样通过返回的 API 注册。
|
||||
*/
|
||||
@@ -104,11 +118,31 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
const locator = new TreeLocator(files, view)
|
||||
const review = new ReviewMode(context, FILES_VIEW, view, files)
|
||||
const uncommitted = new UncommittedView(context, OPEN_COMMAND, decorator)
|
||||
const favorites = new FavoriteViews(context.workspaceState, OPEN_COMMAND, decorator)
|
||||
// 收藏目录、「了解一块代码」共用一池栏位
|
||||
const slots = new SlotPool()
|
||||
const favorites = new FavoriteViews(context.workspaceState, slots, OPEN_COMMAND, decorator)
|
||||
const features = new FeatureViews(context.workspaceState, slots, qa, item => locateItem(item, { adapterFor: languageId => registry.languageFor(languageId), runtime }))
|
||||
const heads = new HeadBases()
|
||||
// 预览对比什么按文件所在的位置定:审查工作副本里的对比 PR 的目标分支,工作区仓库里有改动的对比上一次提交
|
||||
const diffBases = locatedDiffBases([review, heads])
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), diffBases, entries, uri => locator.reveal(uri))
|
||||
const previews = new PreviewEditorProvider(
|
||||
context.extensionUri,
|
||||
registry,
|
||||
runtime,
|
||||
new BlockFilter(context.workspaceState),
|
||||
resolve,
|
||||
qa,
|
||||
namesIn,
|
||||
(index, key) => indexes.find(index, key),
|
||||
diffBases,
|
||||
entries,
|
||||
uri => locator.reveal(uri),
|
||||
(subject, request) => features.ask(subject, request),
|
||||
)
|
||||
const openChat = async (page: Uri | undefined): Promise<void> => {
|
||||
if (page)
|
||||
await previews.openAsking(page)
|
||||
}
|
||||
|
||||
context.subscriptions.push(
|
||||
qa,
|
||||
@@ -139,6 +173,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
review,
|
||||
uncommitted,
|
||||
favorites,
|
||||
features,
|
||||
heads,
|
||||
// 主栏审查时刷新是重读 PR 改过的文件,平时是重读工作区文件树
|
||||
commands.registerCommand('superpowers.codeReader.refresh', () => review.reviewing ? review.reload() : files.refresh()),
|
||||
@@ -151,13 +186,23 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
commands.registerCommand('superpowers.codeReader.showUncommittedTests', () => uncommitted.setTestsHidden(false)),
|
||||
// 「更改」组文件行上的按钮传来的是这一行的节点
|
||||
commands.registerCommand('superpowers.codeReader.stage', (node?: FileNode) => uncommitted.stage(node)),
|
||||
// 目录行上的按钮传来的是这一行的节点;收藏栏标题栏上的按钮不带是哪一栏,所以每个栏位各一条命令
|
||||
// 目录行上的按钮传来的是这一行的节点;栏标题栏上的按钮不带是哪一栏,所以每个栏位各一套命令
|
||||
commands.registerCommand('superpowers.codeReader.addFavorite', (node?: FileNode) => favorites.add(node)),
|
||||
...FAVORITE_SLOTS.flatMap(slot => [
|
||||
commands.registerCommand('superpowers.codeReader.findFeature', async () => openChat(await features.find())),
|
||||
...SLOTS.flatMap(slot => [
|
||||
commands.registerCommand(favoriteCommand('rename', slot), () => favorites.rename(slot)),
|
||||
commands.registerCommand(favoriteCommand('remove', slot), () => favorites.remove(slot)),
|
||||
commands.registerCommand(featureCommand('openChat', slot), () => openChat(features.chatPage(slot))),
|
||||
commands.registerCommand(featureCommand('rename', slot), () => features.rename(slot)),
|
||||
commands.registerCommand(featureCommand('remove', slot), () => features.remove(slot)),
|
||||
]),
|
||||
commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry)),
|
||||
commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry, previews)),
|
||||
// 「了解一块代码」栏里的条目行传来的是这一行:按眼下的代码找到它再打开
|
||||
commands.registerCommand(OPEN_FEATURE_ITEM, async (row?: FeatureRow) => {
|
||||
const found = await features.target(row)
|
||||
if (found)
|
||||
await openFile(found.uri, registry, previews, found.spot)
|
||||
}),
|
||||
// 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签
|
||||
commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)),
|
||||
commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => showSource(previews, args)),
|
||||
|
||||
@@ -76,6 +76,19 @@ describe('stream-json → 问答的进展', () => {
|
||||
.toEqual({ kind: 'result', text: '答案', sessionId: SESSION })
|
||||
})
|
||||
|
||||
it('要了结构化结果(--json-schema):交结果的那次工具调用是「整理结果」,result 行带上交回的结构;result 里那份 JSON 不当回答的字', () => {
|
||||
const output = { groups: [{ name: '页面', items: [] }] }
|
||||
expect(run([
|
||||
line({ type: 'assistant', message: { id: 'm', content: [{ type: 'tool_use', id: 't', name: 'StructuredOutput', input: output }] }, parent_tool_use_id: null }),
|
||||
line({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify(output), structured_output: output, session_id: SESSION }),
|
||||
])).toEqual([
|
||||
{ kind: 'activity', label: '整理结果' },
|
||||
{ kind: 'result', text: '', sessionId: SESSION, output },
|
||||
])
|
||||
expect(run([delta('找到了。'), line({ type: 'result', is_error: false, result: '{}', structured_output: {}, session_id: SESSION })]).at(-1))
|
||||
.toEqual({ kind: 'result', text: '找到了。', sessionId: SESSION, output: {} })
|
||||
})
|
||||
|
||||
it('claude 自己报的失败:取它说的原因', () => {
|
||||
expect(run([line({ type: 'result', subtype: 'success', is_error: true, result: 'Invalid API key · Please run /login' })]))
|
||||
.toEqual([{ kind: 'error', message: 'Invalid API key · Please run /login' }])
|
||||
|
||||
@@ -14,7 +14,8 @@ export type AnswerEvent
|
||||
| { kind: 'text', text: string }
|
||||
/** 正在做的事:「读取 routes.py」「搜索 get_user」 */
|
||||
| { kind: 'activity', label: string }
|
||||
| { kind: 'result', text: string, sessionId: string }
|
||||
/** 答完了;要了结构化结果(--json-schema)时 output 是交回的结构(result 行的 structured_output),没要、没交时没有 */
|
||||
| { kind: 'result', text: string, sessionId: string, output?: unknown }
|
||||
/** Claude Code 自己报的失败(result 行 is_error),进程多半也会以非零退出 */
|
||||
| { kind: 'error', message: string }
|
||||
|
||||
@@ -85,7 +86,14 @@ export function createAnswerParser(): (line: string) => AnswerEvent[] {
|
||||
const errors = Array.isArray(line.errors) ? line.errors.filter(item => typeof item === 'string').join(';') : ''
|
||||
return [{ kind: 'error', message: str(line.result).trim() || errors || str(line.subtype) || '没说原因' }]
|
||||
}
|
||||
return [{ kind: 'result', text: str(line.result).trim() || text, sessionId: str(line.session_id) || sessionId }]
|
||||
// 要了结构化结果时 result 是那份结构的 JSON,不是说的话:回答的字取眼下这条回复
|
||||
const output = line.structured_output
|
||||
return [{
|
||||
kind: 'result',
|
||||
text: output === undefined ? str(line.result).trim() || text : text,
|
||||
sessionId: str(line.session_id) || sessionId,
|
||||
...(output !== undefined && { output }),
|
||||
}]
|
||||
}
|
||||
|
||||
return (raw) => {
|
||||
|
||||
@@ -81,6 +81,28 @@ describe('正在答的问题', () => {
|
||||
expect(events.ends[0][1]).toEqual({ status: 'failed', error: 'Claude Code 没答成:Claude 退出码非零 (1): 网络断了' })
|
||||
})
|
||||
|
||||
it('答成了、listener 却不收(交回的结构不合格):这一轮当出错留着,同报错一样能再画出来,这块能再问', async () => {
|
||||
const { runner, runs } = fakeRunner()
|
||||
const asks = new QaAsks(runner)
|
||||
let ended: () => void = () => {}
|
||||
const done = new Promise<void>((resolve) => {
|
||||
ended = resolve
|
||||
})
|
||||
asks.start(start('1'), {
|
||||
update: () => {},
|
||||
end: async () => {
|
||||
ended()
|
||||
return { status: 'failed', error: '交回的结果不对:没有环节列表。' }
|
||||
},
|
||||
})
|
||||
runs[0].answer({ text: '', sessionId: 's' })
|
||||
await done
|
||||
await Promise.resolve()
|
||||
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'])
|
||||
})
|
||||
|
||||
it('同一块同时只答一个;别的文件里同样记号的块不算同一块', () => {
|
||||
const { runner } = fakeRunner()
|
||||
const asks = new QaAsks(runner)
|
||||
|
||||
@@ -48,7 +48,14 @@ export interface AskStart<Run = Omit<AskRun, 'signal' | 'onEvent'>> {
|
||||
|
||||
export interface AskListener<Result = Answer> {
|
||||
update: (ask: RunningAsk) => void
|
||||
end: (ask: RunningAsk, end: AskEnd<Result>) => void | Promise<void>
|
||||
/** 结束了怎么存、怎么告诉界面。答成了、交回的却不能用(结构不合格)时返回没答成的原因:这一轮当出错留着,同 Claude Code 报错 */
|
||||
end: (ask: RunningAsk, end: AskEnd<Result>) => void | Refusal | Promise<void | Refusal>
|
||||
}
|
||||
|
||||
/** listener 不收这次答成的:给人看的原因 */
|
||||
export interface Refusal {
|
||||
status: 'failed'
|
||||
error: string
|
||||
}
|
||||
|
||||
/** 一种对话怎么跑:同时最多几轮(每轮一个 claude 进程),满了是不开始(refuse,回给人看的话)还是排在后面(queue),以及给人看的话。 */
|
||||
@@ -127,7 +134,11 @@ export class QaAsks<Run extends { signal: AbortSignal } = AskRun, Result = Answe
|
||||
else
|
||||
round.end = end
|
||||
try {
|
||||
await listener.end({ ...ask }, end)
|
||||
const refusal = await listener.end({ ...ask }, end)
|
||||
if (refusal) {
|
||||
round.end = refusal
|
||||
this.rounds.set(askId, round)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
this.next()
|
||||
|
||||
@@ -77,6 +77,18 @@ describe('命令行参数', () => {
|
||||
expect(args).not.toContain('--settings')
|
||||
expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 'session-1', '-p'])
|
||||
})
|
||||
|
||||
it('要结构化结果时带 --json-schema;别的工作区文件夹用 --add-dir 加进来读;照样只读、能接着会话', () => {
|
||||
const args = askArgs({ resume: 'session-1', schema: '{"type":"object"}', addDirs: ['/ws/server', '/ws/docs'] })
|
||||
expect(args.slice(0, 4)).toEqual(['--tools', 'Read,Grep,Glob', '--allowedTools', 'Read,Grep,Glob'])
|
||||
expect(args.slice(args.indexOf('--permission-mode'), args.indexOf('--permission-mode') + 2)).toEqual(['--permission-mode', 'dontAsk'])
|
||||
expect(args).toContain('--strict-mcp-config')
|
||||
expect(args.slice(args.indexOf('--add-dir'), args.indexOf('--add-dir') + 3)).toEqual(['--add-dir', '/ws/server', '/ws/docs'])
|
||||
expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 'session-1', '-p'])
|
||||
expect(args.slice(-2)).toEqual(['--json-schema', '{"type":"object"}'])
|
||||
expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions|Edit|Write|Bash/)
|
||||
expect(askArgs({ addDirs: [] })).not.toContain('--add-dir')
|
||||
})
|
||||
})
|
||||
|
||||
describe('问题连同图片从 stdin 发', () => {
|
||||
@@ -122,6 +134,14 @@ describe('问一次', () => {
|
||||
expect(sent(calls[0]).text).toBe('完整的问题')
|
||||
})
|
||||
|
||||
it('要结构化结果:参数带上 schema 和加进来的目录,交回的结构化结果一并返回;只交了结构化结果、没说话也算答了', async () => {
|
||||
const output = { groups: [] }
|
||||
const { deps, calls } = fakeClaude([{ lines: [init('s1'), line({ type: 'result', subtype: 'success', is_error: false, result: '', structured_output: output, session_id: 's1' })] }])
|
||||
expect(await askClaude(request({ schema: '{"type":"object"}', addDirs: ['/ws/b'] }), deps)).toEqual({ text: '', sessionId: 's1', output })
|
||||
expect(calls[0].args.slice(-2)).toEqual(['--json-schema', '{"type":"object"}'])
|
||||
expect(calls[0].args).toContain('/ws/b')
|
||||
})
|
||||
|
||||
it('选的是「默认」(没给 profile):不带 --settings', async () => {
|
||||
const { deps, calls } = fakeClaude([{ lines: [result('答案', 's1')] }])
|
||||
await askClaude(request(), deps)
|
||||
|
||||
@@ -28,10 +28,12 @@ const PROTOTYPE_DIR = 'docs/prototype/*/openpencil'
|
||||
* 一次提问的命令行参数;问题连同贴的图片从 stdin 按 stream-json 发(见 askClaude),没贴图也一样。只读靠三层:可用的内置工具只剩读、搜(`--tools`);这几个不用再批(`--allowedTools`),
|
||||
* 别的一律拒绝、不等人批(`--permission-mode dontAsk`,盖过 profile 里的 bypassPermissions);
|
||||
* 不加载任何 MCP 服务(`--strict-mcp-config` 又不给 `--mcp-config`),免得 MCP 工具能改东西。
|
||||
* addDirs 是工作目录以外还能读的目录(`--add-dir`);给了 schema 就按它交结构化结果(`--json-schema`)。
|
||||
*/
|
||||
export function askArgs(options: { profilePath?: string, resume?: string } = {}): string[] {
|
||||
export function askArgs(options: { profilePath?: string, resume?: string, schema?: string, addDirs?: readonly string[] } = {}): string[] {
|
||||
const { profilePath, resume, schema, addDirs = [] } = options
|
||||
return [
|
||||
...(options.profilePath ? ['--settings', options.profilePath] : []),
|
||||
...(profilePath ? ['--settings', profilePath] : []),
|
||||
'--tools',
|
||||
READ_ONLY_TOOLS,
|
||||
'--allowedTools',
|
||||
@@ -39,7 +41,8 @@ export function askArgs(options: { profilePath?: string, resume?: string } = {})
|
||||
'--permission-mode',
|
||||
'dontAsk',
|
||||
'--strict-mcp-config',
|
||||
...(options.resume ? ['--resume', options.resume] : []),
|
||||
...(addDirs.length > 0 ? ['--add-dir', ...addDirs] : []),
|
||||
...(resume ? ['--resume', resume] : []),
|
||||
'-p',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
@@ -47,6 +50,7 @@ export function askArgs(options: { profilePath?: string, resume?: string } = {})
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--include-partial-messages',
|
||||
...(schema ? ['--json-schema', schema] : []),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -100,6 +104,10 @@ export interface AskRun {
|
||||
/** 追问:接着 sessionId 那次会话问 prompt;会话找不到了(过期、被清理)就按完整问题重新开一次 */
|
||||
followUp?: { sessionId: string, prompt: string }
|
||||
cwd: string
|
||||
/** cwd 以外还要读的目录(别的工作区文件夹) */
|
||||
addDirs?: string[]
|
||||
/** 按这个 JSON Schema 交结构化结果(`--json-schema`);只要一段话回答时没有 */
|
||||
schema?: string
|
||||
signal: AbortSignal
|
||||
/** 答的过程中:会话 id、回答长出来的字、正在读什么 */
|
||||
onEvent: (event: Exclude<AnswerEvent, { kind: 'result' | 'error' }>) => void
|
||||
@@ -108,6 +116,8 @@ export interface AskRun {
|
||||
export interface Answer {
|
||||
text: string
|
||||
sessionId: string
|
||||
/** 要了结构化结果时交回的结构;没要、没交时没有 */
|
||||
output?: unknown
|
||||
}
|
||||
|
||||
export type AskRunner = (run: AskRun) => Promise<Answer>
|
||||
@@ -122,14 +132,17 @@ function missingSession(err: unknown): boolean {
|
||||
return err instanceof ClaudeError && !(err instanceof ClaudeCancelledError) && /no conversation found/i.test(`${err.message}\n${err.stderr}`)
|
||||
}
|
||||
|
||||
/** 问一次,答完返回最终回答;失败、超时、被叫停时抛 ClaudeError(及其子类),用 askFailure 换成给人看的话。 */
|
||||
/**
|
||||
* 问一次,答完返回最终回答(要了结构化结果的连同交回的结构;只交了结构、没说话也算答了);
|
||||
* 失败、超时、被叫停时抛 ClaudeError(及其子类),用 askFailure 换成给人看的话。
|
||||
*/
|
||||
export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Promise<Answer> {
|
||||
const once = async (prompt: string, resume?: string): Promise<Answer> => {
|
||||
const parse = createAnswerParser()
|
||||
let answer: Answer | undefined
|
||||
let failure: string | undefined
|
||||
await deps.runLines({
|
||||
args: askArgs({ profilePath: run.profilePath, resume }),
|
||||
args: askArgs({ profilePath: run.profilePath, resume, schema: run.schema, addDirs: run.addDirs }),
|
||||
input: buildStreamJsonLine(prompt, run.images),
|
||||
cwd: run.cwd,
|
||||
signal: run.signal,
|
||||
@@ -137,7 +150,7 @@ export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Prom
|
||||
onLine: (line) => {
|
||||
for (const event of parse(line)) {
|
||||
if (event.kind === 'result')
|
||||
answer = { text: event.text, sessionId: event.sessionId }
|
||||
answer = { text: event.text, sessionId: event.sessionId, ...(event.output !== undefined && { output: event.output }) }
|
||||
else if (event.kind === 'error')
|
||||
failure = event.message
|
||||
else
|
||||
@@ -147,7 +160,7 @@ export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Prom
|
||||
})
|
||||
if (failure !== undefined)
|
||||
throw new ClaudeError(failure, '')
|
||||
if (!answer?.text)
|
||||
if (!answer || (!answer.text && answer.output === undefined))
|
||||
throw new ClaudeError('Claude Code 结束了,但没给出回答', '')
|
||||
return answer
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ExtensionContext, Memento, Uri } from 'vscode'
|
||||
import type { AskRunner, CommentAnswer, CommentAsk } from './claudeAsk'
|
||||
import type { RewriteEvent, RewriteRequest } from './codeQa'
|
||||
import type { AskEvent, RewriteEvent, RewriteRequest } from './codeQa'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
@@ -45,6 +45,7 @@ vi.mock('../folderStore', () => ({
|
||||
}))
|
||||
vi.mock('../editor/pathPreview', () => ({
|
||||
pathQaLocation: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }),
|
||||
topQaLocation: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'file:///workspace\na.ts' }),
|
||||
}))
|
||||
vi.mock('./pathAskDescribe', () => ({ describePath: async () => ({ kind: 'file', relativePath: 'a.ts', head: { unavailable: '测试' } }) }))
|
||||
vi.mock('../../cc/profiles', () => ({
|
||||
@@ -262,3 +263,79 @@ describe('重写注释的记录', () => {
|
||||
expect(writeBack).toHaveBeenCalledWith('第二段。')
|
||||
})
|
||||
})
|
||||
|
||||
describe('顶部聊天要结构化结果的一轮(了解一块代码)', () => {
|
||||
/** 记在内存里的 workspaceState:顶部问答记在这里 */
|
||||
function memento(): Pick<Memento, 'get' | 'update'> {
|
||||
const values = new Map<string, unknown>()
|
||||
return { get: <T>(key: string) => values.get(key) as T, update: async (key: string, value: unknown) => void values.set(key, value) }
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const runner = vi.fn<AskRunner>()
|
||||
const qa = new CodeQa(context(), memento(), runner)
|
||||
services.push(qa)
|
||||
return { qa, runner }
|
||||
}
|
||||
|
||||
/** 等这一轮结束的事件 */
|
||||
function ended(qa: CodeQa, askId: string): Promise<Extract<AskEvent, { kind: 'end' }>> {
|
||||
return new Promise((resolve) => {
|
||||
qa.onDidAnswer((event) => {
|
||||
if (event.kind === 'end' && event.ask.askId === askId)
|
||||
resolve(event)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const run = { prompt: '完整的话', followUp: '接着说的话', schema: '{"type":"object"}', addDirs: ['/ws/server'] }
|
||||
const historyOf = (qa: CodeQa) => qa.view(uri, file => file.fsPath, 'feature').path
|
||||
|
||||
it('带上 schema 和加进来的目录;答完交给 settle,记下它给的回答;下一轮接着最近一次的会话说', async () => {
|
||||
const { qa, runner } = setup()
|
||||
const output = { groups: [] }
|
||||
runner.mockResolvedValueOnce({ text: '找到了。', sessionId: 's1', output })
|
||||
const settle = vi.fn(async () => ({ text: '整理好的回答' }))
|
||||
const first = ended(qa, '1')
|
||||
expect(await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, settle)).toBeUndefined()
|
||||
expect(runner.mock.calls[0][0]).toMatchObject({ prompt: '完整的话', schema: '{"type":"object"}', addDirs: ['/ws/server'], cwd: '/workspace' })
|
||||
expect(runner.mock.calls[0][0]).not.toHaveProperty('followUp')
|
||||
expect((await first).end).toEqual({ status: 'done', answer: { text: '整理好的回答', sessionId: 's1', output } })
|
||||
expect(settle).toHaveBeenCalledWith({ text: '找到了。', sessionId: 's1', output })
|
||||
expect(historyOf(qa)).toEqual([expect.objectContaining({ question: '用户登录', answer: '整理好的回答' })])
|
||||
|
||||
runner.mockResolvedValueOnce({ text: '', sessionId: 's1', output })
|
||||
const second = ended(qa, '2')
|
||||
await qa.askTop(uri, { askId: '2', question: '还漏了续费失败重试', images: [] }, run, settle)
|
||||
await second
|
||||
expect(runner.mock.calls[1][0].followUp).toEqual({ sessionId: 's1', prompt: '接着说的话' })
|
||||
expect(historyOf(qa).map(entry => entry.question)).toEqual(['用户登录', '还漏了续费失败重试'])
|
||||
})
|
||||
|
||||
it('settle 说结构不合格:这一轮算失败、说清原因,不记', async () => {
|
||||
const { qa, runner } = setup()
|
||||
runner.mockResolvedValueOnce({ text: '只说了话', sessionId: 's1' })
|
||||
const failed = ended(qa, '1')
|
||||
await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, async () => ({ error: 'AI 没按要求的结构交回结果(只回了一段话)。' }))
|
||||
expect((await failed).end).toEqual({ status: 'failed', error: 'AI 没按要求的结构交回结果(只回了一段话)。' })
|
||||
expect(historyOf(qa)).toEqual([])
|
||||
// 没答成的这一轮留着,页面重新加载后照样画出来
|
||||
await vi.waitFor(() => expect(qa.view(uri, file => file.fsPath, 'feature').current).toMatchObject([{ askId: '1', status: 'failed', error: 'AI 没按要求的结构交回结果(只回了一段话)。' }]))
|
||||
})
|
||||
|
||||
it('删掉这一页的全部问答:记下的没了,正在问的叫停', async () => {
|
||||
const { qa, runner } = setup()
|
||||
runner.mockResolvedValueOnce({ text: '', sessionId: 's1', output: {} })
|
||||
const first = ended(qa, '1')
|
||||
await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, async () => ({ text: '回答' }))
|
||||
await first
|
||||
runner.mockImplementationOnce(ask => new Promise((_resolve, reject) => {
|
||||
ask.signal.addEventListener('abort', () => reject(new ClaudeCancelledError('')))
|
||||
}))
|
||||
const stopped = ended(qa, '2')
|
||||
await qa.askTop(uri, { askId: '2', question: '接着找', images: [] }, run, async () => ({ text: '回答' }))
|
||||
await qa.deleteTop(uri)
|
||||
expect((await stopped).end).toEqual({ status: 'stopped' })
|
||||
expect(historyOf(qa)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -2,22 +2,22 @@ import type { Event, ExtensionContext, Memento } from 'vscode'
|
||||
import type { ClaudeProfile } from '../../cc/profiles'
|
||||
import type { ClaudeImage } from '../../cc/spawnClaude'
|
||||
import type { QaEntryView, QaMessage } from '../editor/messages'
|
||||
import type { PathSubject } from '../editor/pathPreview'
|
||||
import type { PathSubject, TopSubject } from '../editor/pathPreview'
|
||||
import type { PathLocation } from '../folderStore'
|
||||
import type { AskEnd, RunningAsk } from './asks'
|
||||
import type { AskRunner, CommentAnswer, CommentAsk } from './claudeAsk'
|
||||
import type { Answer, AskRunner, CommentAnswer, CommentAsk } from './claudeAsk'
|
||||
import type { ChatEntry, QaEntry, RewriteEntry } from './qaMap'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Disposable, EventEmitter, Uri, window } from 'vscode'
|
||||
import { getSettings, onDidChangeSettings } from '../../settings/store'
|
||||
import { pathQaLocation } from '../editor/pathPreview'
|
||||
import { pathQaLocation, topQaLocation } from '../editor/pathPreview'
|
||||
import { FolderStore, locate } from '../folderStore'
|
||||
import { AskProfiles } from './askProfiles'
|
||||
import { QaAsks, REWRITE_POLICY } from './asks'
|
||||
import { askClaude, askCommentBody } from './claudeAsk'
|
||||
import { describePath } from './pathAskDescribe'
|
||||
import { pathAskFollowUp, pathAskPrompt } from './pathAskPrompt'
|
||||
import { parsePathQa, PATH_ASK_ANCHOR, PATH_QA_STATE_KEY, pathQaEntries, pathQaKey, withoutPathQa, withPathQa } from './pathQa'
|
||||
import { parsePathQa, PATH_ASK_ANCHOR, PATH_QA_STATE_KEY, pathQaEntries, withoutPathQa, withoutPathQaKey, withPathQa } from './pathQa'
|
||||
import { isQaImageType, QA_IMAGE_DIR, removeQaImages, writeQaImages } from './qaImages'
|
||||
import { latestSession, QA_FILE, REWRITE_FILE, withEntry, withoutEntry } from './qaMap'
|
||||
|
||||
@@ -48,6 +48,21 @@ export interface AskRequest {
|
||||
images: ClaudeImage[]
|
||||
}
|
||||
|
||||
/** 在页面顶部问的一轮:question 是记在这一轮上的话,images 是贴进输入框的图片(答成了存进 `.spx/code-qa/`)。 */
|
||||
export interface TopRequest {
|
||||
askId: string
|
||||
question: string
|
||||
images: ClaudeImage[]
|
||||
}
|
||||
|
||||
/** 顶部这一轮怎么问 claude:开新会话时发的完整的话、接着上次的会话说时发的话,要结构化结果时的 JSON Schema,工作目录以外还要读的目录。 */
|
||||
export interface TopRun {
|
||||
prompt: string
|
||||
followUp: string
|
||||
schema?: string
|
||||
addDirs?: string[]
|
||||
}
|
||||
|
||||
/** 重写一段注释:question 是记在这一轮上的话(点按钮是技能命令,之后是用户在输入框里说的)。 */
|
||||
export interface RewriteRequest {
|
||||
askId: string
|
||||
@@ -92,7 +107,7 @@ async function chatEntry(rootPath: string, question: string, sessionId: string,
|
||||
/**
|
||||
* 块下面的对话:问 AI 和重写注释,各自记在各工作区文件夹的一份文件里(`.spx/code-qa.json`、`.spx/comment-rewrites.json`;
|
||||
* 改名、删文件、外部改动的跟随同文件注释,见 FolderStore),挂在块的位置记号上,各自接着自己最近一次的会话。
|
||||
* 预览顶部问的是整个文件或文件夹,按路径记在这个窗口的 workspaceState 里,不进那两份文件。
|
||||
* 预览顶部问的是整个文件、文件夹或「了解一块代码」找的一个功能,记在这个窗口的 workspaceState 里(见 topQaLocation),不进那两份文件。
|
||||
*/
|
||||
export class CodeQa implements Disposable {
|
||||
private readonly store = new FolderStore(QA_FILE)
|
||||
@@ -140,7 +155,7 @@ export class CodeQa implements Disposable {
|
||||
|
||||
/** uri 这份文件或文件夹的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);不在工作区文件夹里时没有。 */
|
||||
imageRoot(uri: Uri): Uri | undefined {
|
||||
const at = pathQaLocation(uri)
|
||||
const at = topQaLocation(uri)
|
||||
return at && Uri.joinPath(at.folder.uri, ...QA_IMAGE_DIR.split('/'))
|
||||
}
|
||||
|
||||
@@ -149,18 +164,18 @@ export class CodeQa implements Disposable {
|
||||
* 不在工作区文件夹里时没地方存,问 AI、重写注释都不可用。
|
||||
* 带的图片换成预览里能用的地址(imageUrl,即 webview.asWebviewUri)。重写记录的回答是交回的正文,没交回时是空的。
|
||||
*/
|
||||
view(uri: Uri, imageUrl: (file: Uri) => string, subject: PathSubject): QaMessage {
|
||||
view(uri: Uri, imageUrl: (file: Uri) => string, subject: TopSubject): QaMessage {
|
||||
const at = locate(uri)
|
||||
const pathAt = pathQaLocation(uri)
|
||||
const top = topQaLocation(uri)
|
||||
const threads = at ? this.store.get(at.folder, at.key) ?? [] : []
|
||||
const rewrites = at ? this.rewriteStore.get(at.folder, at.key) ?? [] : []
|
||||
const pathEntries = pathAt ? pathQaEntries(this.pathMap(), pathQaKey(pathAt.folder.uri.toString(), pathAt.key)) : []
|
||||
const pathEntries = top ? pathQaEntries(this.pathMap(), top.key) : []
|
||||
const root = this.imageRoot(uri)
|
||||
const show = (entry: QaEntry): QaEntryView => ({ ...viewEntry(root, imageUrl, entry), answer: entry.answer })
|
||||
const showRewrite = (entry: RewriteEntry): QaEntryView => ({ ...viewEntry(root, imageUrl, entry), answer: entry.body ?? '', ...(entry.error && { error: entry.error }) })
|
||||
return {
|
||||
type: 'qa',
|
||||
available: pathAt !== undefined,
|
||||
available: top !== undefined,
|
||||
subject,
|
||||
threads: threads.map(({ anchor, entries }) => ({ anchor, entries: entries.map(show) })),
|
||||
path: pathEntries.map(show),
|
||||
@@ -296,19 +311,28 @@ export class CodeQa implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 问这一整个文件或文件夹。材料是路径(文件再加开头若干行,文件夹再加这一层的名字),
|
||||
* 答成了记在 workspaceState 里,不进按块存的那份文件。没答成不记。
|
||||
* 问过的接着最近一次的会话;进行中再问、问不了时返回给人看的原因。
|
||||
*/
|
||||
async askPath(uri: Uri, kind: PathSubject, request: { askId: string, question: string, images: ClaudeImage[] }): Promise<string | undefined> {
|
||||
/** 问这一整个文件或文件夹:材料是路径(文件再加开头若干行,文件夹再加这一层的名字),怎么接着会话、怎么记同 askTop。 */
|
||||
async askPath(uri: Uri, kind: PathSubject, request: TopRequest): Promise<string | undefined> {
|
||||
const at = pathQaLocation(uri)
|
||||
if (!at)
|
||||
return '这个路径不在工作区文件夹里,问答没地方存。'
|
||||
const described = await describePath({ uri, folder: at.folder, isDirectory: kind === 'folder', relativePath: at.key })
|
||||
const run = { prompt: pathAskPrompt(described, request.question), followUp: pathAskFollowUp(described.relativePath, kind, request.question) }
|
||||
return this.askTop(uri, request, run, async answer => ({ text: answer.text }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 subject 这一页的顶部问一轮(文件、文件夹、「了解一块代码」的功能,记在哪见 topQaLocation);问不了、进行中再问时返回给人看的原因。
|
||||
* 问过的接着最近一次的会话说 run.followUp,没问过、会话找不到了发 run.prompt;要结构化结果时带上 run.schema,别的目录要读时带上 run.addDirs。
|
||||
* 答完交给 settle:它给出这一轮记下的回答,或说交回的不合格(这一轮算没答成)。答成了记在 workspaceState 里,带的图片写进 `.spx/code-qa/`;没答成不记。
|
||||
*/
|
||||
async askTop(subject: Uri, request: TopRequest, run: TopRun, settle: (answer: Answer) => Promise<{ text: string } | { error: string }>): Promise<string | undefined> {
|
||||
const top = topQaLocation(subject)
|
||||
if (!top)
|
||||
return '不在工作区文件夹里,问答没地方存。'
|
||||
const { askId, question, images } = request
|
||||
if (!images.every(image => isQaImageType(image.mediaType)))
|
||||
return '只能带 PNG、JPEG、GIF、WebP 图片。'
|
||||
const described = await describePath({ uri, folder: at.folder, isDirectory: kind === 'folder', relativePath: at.key })
|
||||
let profile: ClaudeProfile | undefined
|
||||
try {
|
||||
profile = await this.profiles.current()
|
||||
@@ -316,22 +340,21 @@ export class CodeQa implements Disposable {
|
||||
catch (err) {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
const owner = uri.toString()
|
||||
const key = pathQaKey(at.folder.uri.toString(), at.key)
|
||||
const sessionId = pathQaEntries(this.pathMap(), key).at(-1)?.sessionId
|
||||
const prompt = pathAskPrompt(described, question)
|
||||
const followUp = pathAskFollowUp(described.relativePath, kind, question)
|
||||
const owner = subject.toString()
|
||||
const sessionId = pathQaEntries(this.pathMap(), top.key).at(-1)?.sessionId
|
||||
return this.asks.start({
|
||||
owner,
|
||||
askId,
|
||||
anchor: PATH_ASK_ANCHOR,
|
||||
question,
|
||||
run: {
|
||||
prompt,
|
||||
prompt: run.prompt,
|
||||
images,
|
||||
...(sessionId && { followUp: { sessionId, prompt: followUp } }),
|
||||
...(sessionId && { followUp: { sessionId, prompt: run.followUp } }),
|
||||
...(profile && { profilePath: profile.path }),
|
||||
cwd: at.folder.uri.fsPath,
|
||||
cwd: top.folder.uri.fsPath,
|
||||
...(run.schema !== undefined && { schema: run.schema }),
|
||||
...(run.addDirs && run.addDirs.length > 0 && { addDirs: run.addDirs }),
|
||||
},
|
||||
}, {
|
||||
update: ask => this.answered.fire({ owner, kind: 'update', ask }),
|
||||
@@ -340,10 +363,16 @@ export class CodeQa implements Disposable {
|
||||
this.answered.fire({ owner, kind: 'end', ask, end })
|
||||
return
|
||||
}
|
||||
const settled = await settle(end.answer)
|
||||
if ('error' in settled) {
|
||||
const refusal = { status: 'failed', error: settled.error } as const
|
||||
this.answered.fire({ owner, kind: 'end', ask, end: refusal })
|
||||
return refusal
|
||||
}
|
||||
let entry: QaEntry
|
||||
try {
|
||||
const { id, question: asked, ...rest } = await chatEntry(at.folder.uri.fsPath, question, end.answer.sessionId, profile, images)
|
||||
entry = { id, question: asked, answer: end.answer.text, ...rest }
|
||||
const { id, question: asked, ...rest } = await chatEntry(top.folder.uri.fsPath, question, end.answer.sessionId, profile, images)
|
||||
entry = { id, question: asked, answer: settled.text, ...rest }
|
||||
}
|
||||
catch (err) {
|
||||
const error = `回答没存上:图片写不进 ${QA_IMAGE_DIR}(${err instanceof Error ? err.message : String(err)})`
|
||||
@@ -351,10 +380,10 @@ export class CodeQa implements Disposable {
|
||||
return
|
||||
}
|
||||
await this.enqueuePath(async () => {
|
||||
await this.paths.update(PATH_QA_STATE_KEY, withPathQa(this.pathMap(), key, entry))
|
||||
await this.paths.update(PATH_QA_STATE_KEY, withPathQa(this.pathMap(), top.key, entry))
|
||||
})
|
||||
this.answered.fire({ owner, kind: 'end', ask, end, entryId: entry.id })
|
||||
this.pathChanged.fire([uri])
|
||||
this.answered.fire({ owner, kind: 'end', ask, end: { status: 'done', answer: { ...end.answer, text: settled.text } }, entryId: entry.id })
|
||||
this.pathChanged.fire([subject])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -374,6 +403,25 @@ export class CodeQa implements Disposable {
|
||||
await this.store.update(at.folder, map => withoutEntry(map, at.key, anchor, entryId))
|
||||
}
|
||||
|
||||
/** 删掉 subject 这一页顶部的全部问答和它们带的图片,正在问的叫停(「了解一块代码」删掉一个功能时)。 */
|
||||
async deleteTop(subject: Uri): Promise<void> {
|
||||
const owner = subject.toString()
|
||||
for (const round of this.asks.currentFor(owner))
|
||||
this.asks.stop(round.askId)
|
||||
this.asks.forget(owner, PATH_ASK_ANCHOR)
|
||||
const at = topQaLocation(subject)
|
||||
if (!at)
|
||||
return
|
||||
let images: string[] = []
|
||||
await this.enqueuePath(async () => {
|
||||
const map = this.pathMap()
|
||||
images = pathQaEntries(map, at.key).flatMap(entry => entry.images ?? [])
|
||||
await this.paths.update(PATH_QA_STATE_KEY, withoutPathQaKey(map, at.key))
|
||||
})
|
||||
await removeQaImages(at.folder.uri.fsPath, images)
|
||||
this.pathChanged.fire([subject])
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.settingsSubscription.dispose()
|
||||
this.asks.stopAll()
|
||||
@@ -400,15 +448,14 @@ export class CodeQa implements Disposable {
|
||||
|
||||
/** 删顶部的一次问答和它带的图片,然后告诉开着的预览。 */
|
||||
private async deletePath(uri: Uri, entryId: string): Promise<void> {
|
||||
const at = pathQaLocation(uri)
|
||||
const at = topQaLocation(uri)
|
||||
if (!at)
|
||||
return
|
||||
const key = pathQaKey(at.folder.uri.toString(), at.key)
|
||||
let images: string[] = []
|
||||
await this.enqueuePath(async () => {
|
||||
const map = this.pathMap()
|
||||
images = pathQaEntries(map, key).find(entry => entry.id === entryId)?.images ?? []
|
||||
await this.paths.update(PATH_QA_STATE_KEY, withoutPathQa(map, key, entryId))
|
||||
images = pathQaEntries(map, at.key).find(entry => entry.id === entryId)?.images ?? []
|
||||
await this.paths.update(PATH_QA_STATE_KEY, withoutPathQa(map, at.key, entryId))
|
||||
})
|
||||
await removeQaImages(at.folder.uri.fsPath, images)
|
||||
this.pathChanged.fire([uri])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 预览顶部的问答:按「工作区文件夹 + 相对路径」记在这个窗口的 workspaceState 里。
|
||||
* 文件和文件夹各用各的路径;不写进按块存的 `.spx/code-qa.json`。
|
||||
* 预览顶部的问答:文件、文件夹按「工作区文件夹 + 相对路径」,「了解一块代码」的功能对话页按功能的 id,记在这个窗口的 workspaceState 里。
|
||||
* 不写进按块存的 `.spx/code-qa.json`。
|
||||
*/
|
||||
|
||||
import type { QaEntry } from './qaMap'
|
||||
@@ -32,6 +32,11 @@ export function pathQaKey(folderUri: string, relativePath: string): string {
|
||||
return `${folderUri}\n${relativePath}`
|
||||
}
|
||||
|
||||
/** 「了解一块代码」一个功能的对话记录的键。工作区文件夹的 Uri 带协议名(`file://`),和它撞不上。 */
|
||||
export function featureQaKey(id: string): string {
|
||||
return `feature\n${id}`
|
||||
}
|
||||
|
||||
/** 给人看的相对路径;工作区根写成 `.`。 */
|
||||
export function displayRelativePath(relativePath: string): string {
|
||||
return relativePath === '' ? '.' : relativePath
|
||||
@@ -60,6 +65,15 @@ export function withoutPathQa(map: PathQaMap, key: string, entryId: string): Pat
|
||||
return { ...map, [key]: kept }
|
||||
}
|
||||
|
||||
/** 去掉这个键下的全部问答;没有这个键时原样返回。 */
|
||||
export function withoutPathQaKey(map: PathQaMap, key: string): PathQaMap {
|
||||
if (!(key in map))
|
||||
return map
|
||||
const next = { ...map }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
|
||||
/** 读 workspaceState 里存的表;认不出的丢掉,不让一条坏数据把整份弄丢。 */
|
||||
export function parsePathQa(value: unknown): PathQaMap {
|
||||
if (!isRecord(value))
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SlotKind } from './slots'
|
||||
import { commands, window } from 'vscode'
|
||||
import { freeSlot, slotContext, slotViewId } from './slots'
|
||||
|
||||
/**
|
||||
* 收藏和功能共用的那些栏位眼下被谁占着。各自的记录(收藏的目录、找到的功能)各存各的,占哪一栏都记在这里:
|
||||
* 新占时挑最小的空位(见 freeSlot),占上、放开时跟着设上下文键,那一栏和它标题栏上的按钮才显示、藏起。
|
||||
*/
|
||||
export class SlotPool {
|
||||
private readonly kinds = new Map<number, SlotKind>()
|
||||
|
||||
/** 眼下没被占的里面最小的栏位;都占满了没有。 */
|
||||
free(): number | undefined {
|
||||
return freeSlot(this.kinds.keys())
|
||||
}
|
||||
|
||||
/** 眼下被占着的全部栏位 */
|
||||
taken(): number[] {
|
||||
return [...this.kinds.keys()]
|
||||
}
|
||||
|
||||
/** kind 占上第 slot 栏(开窗口时各自存着的,和新占的)。马上记下,同时再占的不会挑到同一栏。 */
|
||||
async occupy(slot: number, kind: SlotKind): Promise<void> {
|
||||
this.kinds.set(slot, kind)
|
||||
await commands.executeCommand('setContext', slotContext(slot), kind)
|
||||
}
|
||||
|
||||
/** 放开第 slot 栏:这一栏藏起来,栏位给下一个用。 */
|
||||
async release(slot: number): Promise<void> {
|
||||
this.kinds.delete(slot)
|
||||
await commands.executeCommand('setContext', slotContext(slot), false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 弹顶部的输入框问栏标题叫什么(原值整段选中);Esc 或点别处是不改。 */
|
||||
export async function askSlotName(title: string, value: string): Promise<string | undefined> {
|
||||
const name = await window.showInputBox({
|
||||
title,
|
||||
value,
|
||||
prompt: '栏标题上显示的名字',
|
||||
validateInput: text => text.trim() ? undefined : '名字不能为空',
|
||||
})
|
||||
return name?.trim()
|
||||
}
|
||||
|
||||
/** 亮出第 slot 栏(VS Code 给每个视图自带一条 `<视图 id>.focus` 命令)。 */
|
||||
export async function focusSlot(slot: number): Promise<void> {
|
||||
await commands.executeCommand(`${slotViewId(slot)}.focus`)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { freeSlot, slotContext, SLOTS, slotTitle, slotViewId } from './slots'
|
||||
|
||||
describe('新占栏位占哪个', () => {
|
||||
it('没被占的里面最小的:中间空出来的先用', () => {
|
||||
expect(freeSlot([])).toBe(1)
|
||||
expect(freeSlot([1, 2])).toBe(3)
|
||||
expect(freeSlot([1, 3, 4])).toBe(2)
|
||||
expect(freeSlot([5])).toBe(1)
|
||||
})
|
||||
|
||||
it(`${SLOTS.length} 个栏位都占满了:没有`, () => {
|
||||
expect(SLOTS).toEqual(Array.from({ length: 30 }, (_, index) => index + 1))
|
||||
expect(freeSlot(SLOTS)).toBeUndefined()
|
||||
expect(freeSlot(SLOTS.slice(0, -1))).toBe(SLOTS.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('栏位对应的视图、上下文键、标题', () => {
|
||||
it('第 n 个栏位有自己的视图 id 和记着被谁占着的上下文键', () => {
|
||||
expect(slotViewId(3)).toBe('superpowers.codeReader.slot3')
|
||||
expect(slotContext(3)).toBe('superpowers.codeReader.slotKind3')
|
||||
})
|
||||
|
||||
it('标题前的记号分开收藏(文件夹)和功能(放大镜)', () => {
|
||||
expect(slotTitle('favorite', '后端接口')).toBe('📁 后端接口')
|
||||
expect(slotTitle('feature', '用户登录')).toBe('🔍 用户登录')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 代码阅读侧栏里收藏目录、「了解一块代码」找到的功能共用的栏位:VS Code 的视图要在 package.json 里预先声明,
|
||||
* 所以声明了固定这么多个栏(superpowers.codeReader.slot1 … slot30),有收藏或功能占着的才显示。这里是栏位号 1、2、3…
|
||||
*/
|
||||
export const SLOTS: readonly number[] = Array.from({ length: 30 }, (_, index) => index + 1)
|
||||
|
||||
/** 一个栏位被谁占着:favorite 是收藏的目录,feature 是「了解一块代码」找到的一个功能。 */
|
||||
export type SlotKind = 'favorite' | 'feature'
|
||||
|
||||
/** 第 slot 个栏的视图 id */
|
||||
export function slotViewId(slot: number): string {
|
||||
return `superpowers.codeReader.slot${slot}`
|
||||
}
|
||||
|
||||
/** 上下文键:第 slot 个栏位被哪种占着(值是 SlotKind),这一栏才显示、标题栏上挂这种的按钮(package.json 的 when 按它) */
|
||||
export function slotContext(slot: number): string {
|
||||
return `superpowers.codeReader.slotKind${slot}`
|
||||
}
|
||||
|
||||
/** 栏标题前的记号:收藏是文件夹,功能是放大镜。视图标题只能是字,图标字体画不进去,所以用 emoji。 */
|
||||
const SLOT_MARKS: Record<SlotKind, string> = { favorite: '📁', feature: '🔍' }
|
||||
|
||||
/** 栏标题:记号加名字。 */
|
||||
export function slotTitle(kind: SlotKind, name: string): string {
|
||||
return `${SLOT_MARKS[kind]} ${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 新占一个栏位占哪个:没被占的里面最小的,都占满了没有。taken 是收藏、功能眼下占着的全部栏位。
|
||||
* 一个栏位一直被占着、删掉才空出来,用户拖动排过的位置 VS Code 按栏位记着,所以不会错乱。
|
||||
*/
|
||||
export function freeSlot(taken: Iterable<number>): number | undefined {
|
||||
const used = new Set(taken)
|
||||
return SLOTS.find(slot => !used.has(slot))
|
||||
}
|
||||
Reference in new issue
Block a user