✨ 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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user