import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, 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 { DiffBase } 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 { CodeReaderRegistry } from '../registry' import type { LookupSources } from '../ruleLookups' import type { TreeSitterRuntime } from '../runtime' import type { ImportedName, LinkTarget, SymbolNode } from '../symbolLinks' 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 { realpath } from 'node:fs/promises' import { commands, EndOfLine, Position, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode' import { logger } from '../../logging/logger' import { webviewHtml, webviewUiRoot } from '../../webview/html' import { DocumentCallees } from '../calleeKinds' import { loadBlockCalls } from '../callTree/query' import { CallTreeSession } from '../callTree/session' import { commentFollowUp, commentJob, commentRewritePrompt, findBlock, writeCommentBody } from '../commentRewrite' import { PreviewDiff } from '../diff/previewDiff' import { blockReplacement } from '../editText' import { locate } from '../folderStore' import { definitionFor, symbolAt, symbolOccurrences } from '../localSymbols' import { DocumentNames, resolveNames } from '../nameResolution' import { PATH_ASK_ANCHOR } from '../qa/pathQa' import { askPrompt, followUpPrompt } from '../qa/prompt' import { DocumentLookups } from '../ruleLookups' import { resolveReference } from '../symbolLinks' import { definitionAction } from './definitionAction' import { definitionSiteNames, namesOnCard, peekBlock } from './definitionPeek' import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions' import { DefinitionSources } from './definitionSources' import { documentMessage } from './documentMessage' import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation' import { occurrencesOf } from './occurrences' import { languageOf, openingFor } from './openTarget' import { pathPreviewOf } from './pathPreview' import { findRepoSymbol, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath' export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview' /** 连续输入时合并成一次重新解析。 */ const REFRESH_DELAY_MS = 150 /** 预览对比什么:侧栏开着的筛选定(TreeFilters),换了筛选、基准变了发 onDidChangeDiffBase。 */ export interface DiffBases { diffBase: (uri: Uri) => DiffBase | undefined onDidChangeDiffBase: Event } /** * 「代码阅读预览」自定义编辑器。预览和普通编辑器共用同一个 TextDocument: * 块内修改走 WorkspaceEdit,未保存标记、⌘S、⌘Z 都是 VS Code 原生行为。 */ export class PreviewEditorProvider implements CustomTextEditorProvider { private readonly previews = new Set() /** 侧栏「问 AI」打开的预览,解析时把光标放进顶部输入框;已经开着的由 openAsking 自己聚焦 */ private readonly pathFocus = new Set() /** 按文档 uri:等着交给马上要打开的新预览的位置(从文本编辑器切过来时它顶部那一行,或跳到的定义) */ private readonly reveals = new Map() /** 哪个预览眼下能用键盘在高亮的各处出现之间跳,跟着它设快捷键 when 子句用的上下文键 */ readonly navigation = new NavigationContext(value => void commands.executeCommand('setContext', OCCURRENCES_CONTEXT, value)) constructor( private readonly extensionUri: Uri, private readonly registry: CodeReaderRegistry, private readonly runtime: TreeSitterRuntime, private readonly filter: BlockFilter, /** 库规则标的引用去哪儿查定义(交给标它的规则) */ readonly resolveReference: (reference: RuleReference) => Promise, /** 问 AI:各预览共用,同一份文件开在几处时问答、正在答的问题都一样 */ readonly qa: CodeQa, /** 工作区里别的文件定义的、原名叫 name 的中文名(languageId 是查的这份文档的语言) */ readonly indexedNames: (languageId: string, name: string) => Promise, /** 工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts;接口的调用树查契约和前端请求时用) */ readonly indexed: IndexFind, /** 预览标差异时对比什么 */ readonly diffBases: DiffBases, /** 哪些文件是项目写明的入口、预览里哪些名字的块标成入口 */ readonly entries: Pick, /** 在代码阅读侧栏的树上定位一个目录(仓库链接指到目录时);树上没有它时返回 false */ readonly revealInTree: (uri: Uri) => Promise, ) {} /** 侧栏「问 AI」打开预览,并把光标放进顶部输入框。已经开着的就揭到前面再聚焦。 */ async openAsking(uri: Uri): Promise { const key = uri.toString() this.pathFocus.add(key) try { await commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true }) } finally { // 新开的预览在解析时已经拿走标记;还在的是本来就开着的那页,这里聚焦 if (this.pathFocus.delete(key)) { for (const preview of this.previews) { if (preview.uri.toString() === key) preview.focusPathAsk() } } } } resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { // 问答带的图片(工作区文件夹的 .spx/code-qa/)也要能读,历史里才画得出缩略图 const imageRoot = this.qa.imageRoot(pathPreviewOf(document.uri)?.uri ?? document.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() const focusPath = this.pathFocus.delete(key) const preview = new Preview(document, panel, this, this.registry, this.runtime, this.filter, this.reveals.get(key), focusPath) this.reveals.delete(key) this.previews.add(preview) panel.onDidDispose(() => { this.previews.delete(preview) this.navigation.update(preview, false) preview.dispose() }) } /** 「跳到下一处 / 上一处高亮」:发给能跳的那个预览,从命令面板运行时发给活动的预览。 */ moveOccurrence(step: 1 | -1): void { this.navigation.target(this.previews, preview => preview.active)?.moveOccurrence(step) } /** 让已打开的预览按当前注册表重发;给了 languageId 就只刷这种语言的。 */ refresh(languageId?: string): void { for (const preview of this.previews) { if (languageId === undefined || preview.languageId === languageId) void preview.push() } } /** * 用 open 打开 uri 的预览并滚到 reveal:新建的预览随第一份文档带过去; * open 只是切到了已经开着的预览(没有新建)时,直接发给这个预览,不重新打开。 */ async openAt(uri: Uri, reveal: Reveal, open: () => Thenable): Promise { const key = uri.toString() this.reveals.set(key, reveal) let claimed: boolean try { await open() claimed = this.reveals.get(key) !== reveal } finally { // 打开命令等新预览解析完才返回;没打开成时也不能把这一处留给以后别处的打开 this.reveals.delete(key) } if (!claimed) [...this.previews].find(p => p.uri.toString() === key && p.active)?.reveal(reveal) } /** uri 的预览视口顶部那块的起始行;几个编辑器组里都开着这个文件的预览时取活动的那个。 */ visibleLine(uri: Uri): number | undefined { const key = uri.toString() const previews = [...this.previews].filter(p => p.uri.toString() === key) return (previews.find(p => p.active) ?? previews[0])?.visibleLine } } class Preview { /** webview 发过 ready 之后才发消息;标签页切走时页面被销毁,切回来重新加载、再发 ready,这期间也不发 */ private ready = false /** 侧栏「问 AI」打开的:页面准备好后把光标放进顶部输入框 */ private pendingPathFocus = false /** 解析是异步的:只发最后一次请求的结果,慢的旧结果不能盖掉新的 */ private latest = 0 private timer: NodeJS.Timeout | undefined private readonly disposables: Disposable[] /** 名字的定义去哪儿查:库规则标了引用的问规则,其余问语言扩展 */ private readonly definitions: DefinitionSources /** webview 报来的视口顶部那块的起始行 */ visibleLine: number | undefined /** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */ private sent: SentDocument | undefined /** 能看调用树的块(函数、类、接口卡片)落在几棵调用树里。文档发出去之后再数,不挡住第一次预览 */ private readonly callTrees = new CallTreeSession( message => this.post(message), () => { if (!this.sent || this.sent.version !== this.document.version) return undefined return { version: this.sent.version, blocks: this.sent.blocks, load: (block, cancelled) => loadBlockCalls(this.document, block, this.owner.indexed, cancelled), } }, ) /** 最近一次发出去的文档里语言插件自己认得出的名字(LanguageAdapter.symbols):语言扩展查不到定义、各处出现时用 */ private symbols: { version: number, list: LocalSymbol[] } | undefined /** 最近一次发出去的文档里各条导入引进来的名字:注释里不带路径的引用按它找导入的定义 */ private imports: ImportedName[] = [] /** 这份文档各版本显示成中文名的名字 */ private readonly names: DocumentNames /** 库规则问的名字的定义查到了什么:查到的变了就重发文档 */ private readonly lookups: DocumentLookups /** 调用的名字定义成哪种块(分新建还是调用):查到的变了就重发文档 */ private readonly callees: DocumentCallees /** 这份文件相比对比基准改了什么:发完文档再算、补发 */ private readonly diff: PreviewDiff constructor( private readonly document: TextDocument, private readonly panel: WebviewPanel, private readonly owner: PreviewEditorProvider, private readonly registry: CodeReaderRegistry, private readonly runtime: TreeSitterRuntime, private readonly filter: BlockFilter, /** * 随下一份真正发出去的文档走、只走一次:新预览第一份文档要滚到的位置,或页面正在重新加载时收不到的 reveal。 * 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复 */ private pendingReveal: Reveal | undefined, focusPath: boolean, ) { this.pendingPathFocus = focusPath this.definitions = new DefinitionSources({ rule: async reference => ruleDefinitions(await owner.resolveReference(reference)), language: async (offset) => { const found = await languageDefinitions(document.uri, document.positionAt(offset)) return found.length > 0 ? found : this.localDefinitions(offset) }, }) this.names = new DocumentNames((version, names) => { if (this.ready && version === document.version) this.post({ type: 'displayNames', version, names }) }) this.lookups = new DocumentLookups(() => this.schedulePush()) this.callees = new DocumentCallees(() => this.schedulePush()) this.diff = new PreviewDiff( () => owner.diffBases.diffBase(document.uri), source => this.readOld(source), (diff) => { if (this.ready) this.post({ type: 'diff', diff }) }, text => logger.add({ level: 'warn', source: 'codeReader', message: `${document.uri.fsPath}:${text}` }), ) this.disposables = [ owner.diffBases.onDidChangeDiffBase(() => { if (this.ready && this.sent) void this.diff.update(this.sent) }), panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)), panel.onDidChangeViewState(() => { if (panel.visible) return // 页面被销毁了,高亮跟着没了 this.ready = false owner.navigation.update(this, false) }), workspace.onDidChangeTextDocument((e) => { if (e.document !== document || e.contentChanges.length === 0) return this.names.edited(document.version, e.contentChanges.map(change => ({ offset: change.rangeOffset, length: change.rangeLength, text: change.text }))) this.schedulePush() }), workspace.onDidChangeConfiguration((e) => { if (this.ready && e.affectsConfiguration('editor.hover', document)) this.postHoverDelay() if (this.ready && e.affectsConfiguration('editor.stickyScroll', document)) this.postStickyScroll() }), // 页面没 ready 时收不到,等它 ready 再随第一份文档之前补发 filter.subscribe((state) => { if (this.ready) this.post({ type: 'filter', ...state }) }), // 项目的入口变了:重新发文档,卡片左边的入口图标跟着变 owner.entries.onDidChange((uris) => { if (uris.some(uri => uri.toString() === document.uri.toString())) this.schedulePush() }), owner.qa.onDidChange((uris) => { if (uris.some(uri => uri.toString() === this.subject().uri.toString())) this.postQa() }), owner.qa.profiles.subscribe((choice) => { if (this.ready) this.post({ type: 'askProfiles', ...choice }) }), owner.qa.onDidAnswer((event) => { if (event.owner !== this.subject().uri.toString() || !this.ready) return if (event.kind === 'update') { const { askId, text, activity } = event.ask this.post({ type: 'answer', askId, text, ...(activity !== undefined && { activity }) }) return } const { end, entryId } = event this.post({ type: 'answerEnd', askId: event.ask.askId, status: end.status, ...(end.status === 'failed' && { error: end.error }), ...(end.status === 'done' && { text: end.answer.text, entryId }), }) this.postQa() }), owner.qa.onDidRewrite((event) => { if (event.owner !== this.subject().uri.toString() || !this.ready) return if (event.kind === 'update') { const { askId, question, text, activity } = event.ask this.post({ type: 'rewriteProgress', askId, question, text, ...(activity !== undefined && { activity }) }) return } const { end, entryId } = event this.post({ type: 'rewriteEnd', askId: event.ask.askId, status: end.status, ...(end.status !== 'stopped' && end.error !== undefined && { error: end.error }), ...(end.status === 'done' && end.body !== undefined && { text: end.body }), ...(entryId !== undefined && { entryId }), }) this.postQa() }), ] } get languageId(): string { return this.document.languageId } get uri(): Uri { return this.document.uri } get active(): boolean { return this.panel.active } async push(): Promise { clearTimeout(this.timer) if (!this.ready) return const request = ++this.latest this.callTrees.cancel() if (pathPreviewOf(this.document.uri)) { this.sent = undefined const reveal = this.pendingReveal this.pendingReveal = undefined this.post({ type: 'document', version: this.document.version, languageId: '', blocks: [], attributes: [], translators: [], translatorId: '', names: [], ...(reveal && { reveal }), }) return } const { languageId, version } = this.document const source = this.document.getText() const session = this.lookups.session() const callees = this.callees.session() const { message, displayNames, symbols, imports } = await documentMessage({ languageId, version, getText: () => source, fileName: this.document.fileName, entryNames: this.owner.entries.namesOf(this.document.uri) }, this.registry, this.runtime, (text) => { logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` }) }, session.lookup, callees.calleeKind) if (request !== this.latest || !this.ready) return this.definitions.remember(message.version, message.blocks) // 解析失败的文档没有块:问 AI 找不到块,也不拿它比差异 this.sent = message.error === undefined ? { version: message.version, blocks: message.blocks, source } : undefined this.symbols = { version, list: symbols } this.imports = imports const reveal = this.pendingReveal this.pendingReveal = undefined this.post({ ...message, names: this.names.known(version), ...(reveal && { reveal }) }) if (this.sent) void this.diff.update(this.sent) // 文档在解析期间又改了的,等下一次发文档再查 if (version === this.document.version) { this.names.resolve(version, report => resolveNames(displayNames, message.blocks, this.nameSources(), report)) this.lookups.resolve(version, session.requests(), this.lookupSources()) this.callees.resolve(version, callees.requests(), offset => this.calleeKind(offset)) this.callTrees.refresh() } } /** * 对比基准里的旧版本排成视图数据:和这份文档同一种语言、同一个翻译插件和库规则;规则查定义、调用的种类用这份文档眼下查到的答案, * 两边同样的写法翻得一样,不会因为答案晚到被当成改了;项目入口也按眼下的标,两边的入口块一样。旧版本里规则出的错不记(记的是现在这份的)。 */ private async readOld(source: string): Promise { const { message } = await documentMessage( { languageId: this.document.languageId, version: this.document.version, getText: () => source, fileName: this.document.fileName, entryNames: this.owner.entries.namesOf(this.document.uri) }, this.registry, this.runtime, () => {}, this.lookups.session().lookup, this.callees.session().calleeKind, ) return message.error === undefined ? { blocks: message.blocks, attributes: message.attributes } : undefined } /** 这份文档里 offset 处名字的定义是哪种块:语言扩展查到的几处定义里第一处认得出的;语言扩展没答是 undefined。 */ private async calleeKind(offset: number): Promise { const { document } = this const found = await languageDefinitions(document.uri, document.positionAt(offset)) if (found.length === 0) return undefined for (const location of found) { const kind = await definitionKind(location, languageId => this.registry.languageFor(languageId), this.runtime) if (kind) return kind } return null } /** 这份文档里语言插件自己认得出、盖住 offset 的名字(只认最近一次发出去的那个版本)。 */ private localSymbol(offset: number): LocalSymbol | undefined { return this.symbols?.version === this.document.version ? symbolAt(this.symbols.list, offset) : undefined } /** 语言扩展查不到定义时:语言插件自己认得出的名字的定义(见 definitionFor)。 */ private localDefinitions(offset: number): FoundLocation[] { const symbol = this.localSymbol(offset) const target = symbol && definitionFor(symbol, offset) if (!target) return [] const { document } = this return [{ uri: document.uri, range: new Range(document.positionAt(target.startIndex), document.positionAt(target.endIndex)) }] } /** offset 处名字的各处出现:先问语言扩展,它答不上来时用语言插件自己认得出的名字(见 symbolOccurrences)。 */ private async occurrences(offset: number): Promise { const found = await occurrencesOf(this.document, offset) const symbol = found.length > 0 ? undefined : this.localSymbol(offset) return symbol ? symbolOccurrences(symbol) : found } /** 这份文档里 offset 处名字的定义(语言扩展查的)落在哪儿。 */ private async definitionStarts(offset: number): Promise { const { document } = this return (await languageDefinitions(document.uri, document.positionAt(offset))) .map(({ uri, range }) => ({ uri: uri.toString(), line: range.start.line, character: range.start.character })) } /** 找中文名落在哪些名字上时问的:这份文档的语言扩展、工作区里的中文名索引。 */ private nameSources(): NameSources { const { document } = this return { uri: document.uri.toString(), occurrences: async offset => (await this.occurrences(offset)).map(({ start, end }) => ({ start, end })), definitions: offset => this.definitionStarts(offset), indexed: name => this.owner.indexedNames(document.languageId, name), } } /** 查库规则问的名字时问的:规则的工作区索引、这份文档的语言扩展。 */ private lookupSources(): LookupSources { return { find: (index, key) => this.owner.indexed(index, key), definitions: offset => this.definitionStarts(offset), } } /** 跳到高亮着的名字的下一处 / 上一处出现;页面不在就算了(页面重新加载时高亮已经没了)。 */ moveOccurrence(step: 1 | -1): void { if (this.ready) this.post({ type: 'moveOccurrence', step }) } /** 滚到 reveal:页面在就直接发,正在重新加载就等它发 ready 后随文档带过去。 */ reveal(reveal: Reveal): void { if (this.ready) this.post({ type: 'reveal', ...reveal }) else this.pendingReveal = reveal } dispose(): void { this.callTrees.dispose() clearTimeout(this.timer) this.names.dispose() this.lookups.dispose() this.callees.dispose() for (const disposable of this.disposables) disposable.dispose() } private post(message: ExtensionToWebview): void { void this.panel.webview.postMessage(message) } private postQa(): void { if (!this.ready) return const subject = this.subject() 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' } } /** 把光标放进顶部输入框;页面还没准备好就等 ready。 */ focusPathAsk(): void { if (!this.ready) { this.pendingPathFocus = true return } this.pendingPathFocus = false this.post({ type: 'focusPathAsk' }) } /** profile 列表每次现读(同看板),读完页面还在才发。 */ private async postProfiles(): Promise { const choice = await this.owner.qa.profiles.choice() if (this.ready) this.post({ type: 'askProfiles', ...choice }) } /** * 问 anchor 那块代码:按最近一次发出去的块树找到它,把完整问题和追问的话都交给 CodeQa,接不接着上次的会话由它定。 * 找不到这块(代码刚改过、还没重新发)或问不了时,直接回一条失败。 */ private async ask(askId: string, anchor: string, question: string, images: ClaudeImage[]): Promise { const subject = this.subject() if (anchor === PATH_ASK_ANCHOR) { const refused = await this.owner.qa.askPath(subject.uri, subject.kind, { askId, question, images }) if (refused !== undefined) this.post({ type: 'answerEnd', askId, status: 'failed', error: refused }) else this.postQa() return } const at = locate(subject.uri) const block = at && this.sent && { path: at.key, languageId: this.document.languageId, blocks: this.sent.blocks, source: this.sent.source, anchor } const prompt = block && askPrompt(block, question) const followUp = block && followUpPrompt(block, question) const refused = prompt === undefined || followUp === undefined ? '这块代码刚改过,找不到了。等预览刷新后重新点「问 AI」再问。' : await this.owner.qa.ask(subject.uri, { askId, anchor, question, prompt, followUp, images }) if (refused !== undefined) { this.post({ type: 'answerEnd', askId, status: 'failed', error: refused }) return } this.postQa() } private schedulePush(): void { clearTimeout(this.timer) this.timer = setTimeout(() => void this.push(), REFRESH_DELAY_MS) } private async handleMessage(msg: WebviewToExtension): Promise { switch (msg.type) { case 'ready': this.ready = true // 先于文档发:第一份文档按筛选画,不会先整份露出来再藏 this.post({ type: 'filter', ...this.filter.current }) this.postHoverDelay() this.postStickyScroll() this.postQa() void this.postProfiles() await this.push() if (this.pendingPathFocus) this.focusPathAsk() return case 'ask': await this.ask(msg.askId, msg.anchor, msg.question, msg.images) return case 'selectAskProfile': try { await this.owner.qa.profiles.select(msg.id) } catch (err) { void window.showErrorMessage(err instanceof Error ? err.message : String(err)) await this.postProfiles() } return case 'stopAsk': this.owner.qa.stop(msg.askId) return case 'deleteQa': await this.owner.qa.delete(this.subject().uri, msg.anchor, msg.entryId) return case 'setFilter': await this.filter.set(msg.excluded) return case 'setOnlyChanges': await this.filter.setOnlyChanges(msg.on) return case 'edit': await replaceDocumentLines(this.document, msg.startLine, msg.endLine, msg.text) return case 'rewriteComment': await this.rewrite(msg.askId, msg.anchor, msg.request, msg.images) return case 'stopRewrite': this.owner.qa.stopRewrite(msg.askId) return case 'deleteRewrite': await this.owner.qa.deleteRewrite(this.subject().uri, msg.anchor, msg.entryId) return case 'forgetRound': if (msg.kind === 'ask') this.owner.qa.forgetAsk(this.subject().uri, msg.anchor) else this.owner.qa.forgetRewrite(this.subject().uri, msg.anchor) return case 'selectTranslator': await this.registry.selectTranslator(this.languageId, msg.id) this.owner.refresh(this.languageId) return case 'visibleLine': this.visibleLine = msg.line return case 'probeDefinition': await this.probeDefinition(msg.id, msg.version, msg.offset) return case 'goToDefinition': await this.goToDefinition(msg.version, msg.offset) return case 'peekDefinition': await this.peekDefinition(msg.id, msg.version, msg.offset) return case 'peekLink': await this.peekLink(msg.id, msg.path, msg.symbol) return case 'findOccurrences': await this.findOccurrences(msg.id, msg.version, msg.offset) return case 'openRepoLink': await this.openRepoLink(msg.path, msg.symbol) return case 'checkRepoLinks': await this.checkRepoLinks(msg.id, msg.paths, msg.symbols) return case 'checkRepoImages': await this.checkRepoImages(msg.id, msg.paths) return case 'openCallTree': this.callTrees.open(msg.version, msg.anchor) return case 'goToCallNode': await this.goToCallNode(Uri.parse(msg.uri), new Position(msg.line, msg.character)) return case 'occurrenceNavigation': this.owner.navigation.update(this, msg.available) } } /** 回答 webview 第 id 次询问:offset 处的名字在这份文档里出现在哪几处。下标属于别的文档版本时不答,webview 换了文档版本会清掉高亮 */ private async findOccurrences(id: number, version: number, offset: number): Promise { const { document } = this if (version !== document.version) return const occurrences = await this.occurrences(offset) if (version === document.version && this.ready) this.post({ type: 'occurrencesFound', id, occurrences }) } /** 回答 webview 第 id 次询问:offset 处的名字有没有定义。下标属于别的文档版本时不答,webview 收到新文档后会重新问 */ private async probeDefinition(id: number, version: number, offset: number): Promise { const { document } = this if (version !== document.version) return const found = (await this.definitions.locate(version, offset)).length > 0 if (version === document.version && this.ready) this.post({ type: 'definitionProbed', id, found }) } /** ⌘ / Ctrl + 点击:跳到 offset 处名字的定义,几处时先让用户挑。 */ private async goToDefinition(version: number, offset: number): Promise { const { document } = this if (version !== document.version) return const current = document.uri.toString() let action = definitionAction(await findDefinitions(await this.definitions.locate(version, offset)), current) if (action.kind === 'pick') { const picked = await pickDefinition(action.targets) action = definitionAction(picked ? [picked] : [], current) } if (action.kind === 'none' || action.kind === 'pick') return const { target } = action if (action.kind === 'here') { this.reveal({ line: target.line, definition: target.offset }) return } try { await this.openElsewhere(target.location.uri, { range: target.location.range, offset: target.offset }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'codeReader', message: `没能打开定义 ${target.uri}:${message}` }) void window.showErrorMessage(`代码阅读:没能打开定义所在的 ${workspace.asRelativePath(target.location.uri)}`) } } /** VS Code 的悬停延迟(按这份文件取,语言自己的设置优先):页面按它弹、收定义卡片。 */ private postHoverDelay(): void { const hover = workspace.getConfiguration('editor.hover', this.document) this.post({ type: 'hoverDelay', delay: hover.get('delay', 300), hidingDelay: hover.get('hidingDelay', 300) }) } /** VS Code 的 Sticky Scroll 设置(按这份文件取,语言自己的设置优先):页面按它把滚出去一半的卡片钉在顶上。 */ private postStickyScroll(): void { const sticky = workspace.getConfiguration('editor.stickyScroll', this.document) this.post({ type: 'stickyScroll', enabled: sticky.get('enabled', true), maxLineCount: sticky.get('maxLineCount', 5) }) } /** * 回答 webview 第 id 次要定义卡片:offset 处名字的第一处定义所在那块(见 peekAt);指着的名字就是定义本身时没有。 * 下标属于别的文档版本时不答,webview 换了文档版本会收起浮窗 */ private async peekDefinition(id: number, version: number, offset: number): Promise { const { document } = this if (version !== document.version) return const [definition] = await findDefinitions(await this.definitions.locate(version, offset)) const own = definition?.uri === document.uri.toString() && definition.offset === offset const peek = definition && !own ? await this.peekAt(definition.location.uri, definition.offset) : undefined if (version === document.version && this.ready) this.post({ type: 'definitionPeeked', id, ...(peek && { peek }) }) } /** 回答 webview 第 id 次要注释里的链接指的符号的卡片:符号同点这个链接时找的那个(symbolTarget),找不到没有。 */ private async peekLink(id: number, rel: string | undefined, symbol: string): Promise { let peek: DefinitionPeek | undefined try { const target = await this.symbolTarget(rel, symbol) peek = target && await this.peekAt(Uri.parse(target.uri), target.selection.startIndex) } catch (err) { const link = rel === undefined ? `[${symbol}]` : `${rel}#${symbol}` logger.add({ level: 'warn', source: 'codeReader', message: `没能找 ${link} 指的符号:${err instanceof Error ? err.message : String(err)}` }) } if (this.ready) this.post({ type: 'definitionPeeked', id, ...(peek && { peek }) }) } /** * uri 里 offset 处的定义的卡片:盖住它的那块(peekBlock)。同一份文件用最近发出去的那份文档的块和中文名; * 别的文件打开它、按它的语言排视图数据(同它自己的预览:翻译插件、库规则、项目入口;规则查名字的定义、调用的种类不等), * 中文名只认起了中文名的定义本身。语言没有代码阅读、解析失败、没有块盖住定义处时没有。 */ private async peekAt(uri: Uri, offset: number): Promise { try { const target = await workspace.openTextDocument(uri) const line = target.positionAt(offset).line const path = workspace.asRelativePath(uri) if (uri.toString() === this.document.uri.toString()) { const { sent } = this const block = sent?.version === target.version ? peekBlock(sent.blocks, offset, line) : undefined return block && { path, line, block, names: namesOnCard(block, this.names.known(target.version)) } } const { message, displayNames } = await documentMessage( { languageId: target.languageId, version: target.version, getText: () => target.getText(), fileName: target.fileName, entryNames: this.owner.entries.namesOf(uri) }, this.registry, this.runtime, // 那个文件里规则出的错由它自己的预览记 () => {}, ) const block = message.error === undefined ? peekBlock(message.blocks, offset, line) : undefined return block && { path, line, block, names: namesOnCard(block, definitionSiteNames(displayNames)) } } catch (err) { logger.add({ level: 'warn', source: 'codeReader', message: `没能读出 ${uri.toString()} 里定义的那块:${err instanceof Error ? err.message : String(err)}` }) return undefined } } /** * 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开,给了 spot 就滚过去、闪这处; * 其余用 VS Code 默认的打开方式,给了 spot 就选中它(见 openingFor)。目标是否按「预览标签」打开听 VS Code 的设置, * workbench.editor.enablePreviewFromCodeNavigation 为假时先把当前这个预览标签固定住,免得被目标替掉。 */ private async openElsewhere(uri: Uri, spot?: TargetSpot): Promise { if (this.panel.active && !workspace.getConfiguration('workbench.editor').get('enablePreviewFromCodeNavigation', false)) await commands.executeCommand('workbench.action.keepEditor') const options: TextDocumentShowOptions = { viewColumn: this.panel.viewColumn } const opening = openingFor(await languageOf(uri), languageId => this.registry.languageFor(languageId) !== undefined, spot) if (opening.kind === 'default') { await commands.executeCommand('vscode.open', uri, { ...options, ...(opening.selection && { selection: opening.selection }) }) return } const open = (): Thenable => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options) if (opening.reveal) await this.owner.openAt(uri, opening.reveal, open) else await open() } /** 点了调用树上的一个节点:打开它在的文件,滚到这一处(同 ⌘ + 点击跳到别的文件)。 */ private async goToCallNode(uri: Uri, position: Position): Promise { try { const target = await workspace.openTextDocument(uri) await this.openElsewhere(uri, { range: new Range(position, position), offset: target.offsetAt(position) }) } catch (err) { logger.add({ level: 'warn', source: 'codeReader', message: `调用树里的 ${uri.toString()} 打不开:${err instanceof Error ? err.message : String(err)}` }) } } /** 预览里这批仓库路径,哪些文件或目录不在;这批不带路径的引用,哪些找不到。 */ private async checkRepoLinks(id: number, paths: string[], symbols: string[]): Promise { const root = await repoRootOf(this.document.uri.fsPath) const asked = new Map>() const symbolsOf = (uri: string): Promise => { const known = asked.get(uri) ?? this.symbolNodes(uri) asked.set(uri, known) return known } const [missing, unresolved] = await Promise.all([ Promise.all(paths.map(async (rel) => { if (root === undefined || await resolveRepoEntry(root, rel) === undefined) return rel return undefined })), Promise.all(symbols.map(async symbol => await this.resolveSymbolLink(symbol, symbolsOf) ? undefined : symbol)), ]) if (this.ready) this.post({ type: 'repoLinksChecked', id, missing: missing.filter(rel => rel !== undefined), unresolved: unresolved.filter(symbol => symbol !== undefined) }) } /** 仓库根放进这个预览能读的本地目录,图片地址才能加载。真实位置和 git 给的路径不一样时两个都放。 */ private addLocalRoot(fsPath: string): void { const uri = Uri.file(fsPath) const roots = this.panel.webview.options.localResourceRoots ?? [] if (roots.some(root => root.fsPath === uri.fsPath)) return this.panel.webview.options = { ...this.panel.webview.options, localResourceRoots: [...roots, uri] } } private async allowRepoRoot(root: string): Promise { this.addLocalRoot(root) try { const real = await realpath(root) if (real !== root) this.addLocalRoot(real) } catch { // 仓库根这会儿读不到真实位置,就按 git 给的路径放行 } } /** 这批仓库图片哪些在。在的带回预览里能直接用的地址;不在、不是图片、跑出仓库的没有地址。 */ private async checkRepoImages(id: number, paths: string[]): Promise { const root = await repoRootOf(this.document.uri.fsPath) if (root) await this.allowRepoRoot(root) const images = await Promise.all(paths.map(async (rel) => { if (root === undefined || !isRepoImagePath(rel)) return { path: rel } const entry = await resolveRepoEntry(root, rel) if (!entry || entry.isDirectory) return { path: rel } const src = this.panel.webview.asWebviewUri(Uri.file(entry.fsPath)).toString() return { path: rel, src } })) if (this.ready) this.post({ type: 'repoImagesChecked', id, images }) } /** * 打开注释里的仓库链接:目录在代码阅读侧栏的树上定位,文件用编辑器打开。 * 带了符号就按文档符号的名字跳过去;文件或符号没有就不打开。没有路径的是不带路径的引用,按这份文件找(见 resolveSymbolLink)。 */ private async openRepoLink(rel: string | undefined, symbol?: string): Promise { const link = rel ?? `[${symbol}]` try { if (symbol !== undefined) { const target = await this.symbolTarget(rel, symbol) if (!target) { this.markRepoLinkBroken(rel, symbol) return } await this.showLinkTarget(target) this.markRepoLinkOpened(rel, symbol) return } if (rel === undefined) return const root = await repoRootOf(this.document.uri.fsPath) const entry = root === undefined ? undefined : await resolveRepoEntry(root, rel) if (!entry) { this.markRepoLinkBroken(rel) return } const uri = Uri.file(entry.fsPath) if (entry.isDirectory) { if (!await this.owner.revealInTree(uri)) void window.showInformationMessage(`代码阅读:侧栏的树上没有 ${rel}`) this.markRepoLinkOpened(rel) return } await this.openElsewhere(uri) this.markRepoLinkOpened(rel) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'codeReader', message: `没能打开仓库链接 ${link}:${message}` }) void window.showErrorMessage(`代码阅读:没能打开 ${link}`) } } /** * 链接指的符号在哪:没有 path 的是不带路径的引用,按这份文件找(resolveSymbolLink);有 path 的在那个文件的文档符号里找(locateRepoSymbol)。 * 文件没有、是目录、符号没有时没有。点链接、悬停链接看定义卡片都按它找。 */ private async symbolTarget(rel: string | undefined, symbol: string): Promise { if (rel === undefined) return this.resolveSymbolLink(symbol) const root = await repoRootOf(this.document.uri.fsPath) const entry = root === undefined ? undefined : await resolveRepoEntry(root, rel) return entry && !entry.isDirectory ? this.locateRepoSymbol(Uri.file(entry.fsPath), symbol) : undefined } /** 用文档符号按名字找(`Agent`、`Agent.owner_id`);没有这个名字就没有,不打开到文件顶上。 */ private async locateRepoSymbol(uri: Uri, symbol: string): Promise { const found = findRepoSymbol(await this.symbolNodes(uri.toString()), symbol) return found && { uri: uri.toString(), selection: found.selection } } /** 一个文件的文档符号(vscode.executeDocumentSymbolProvider):先把文件读进来(不显示),范围换成那个文件里的下标。 */ private async symbolNodes(uri: string): Promise { const doc = await workspace.openTextDocument(Uri.parse(uri)) const symbols = await commands.executeCommand('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 = uri => this.symbolNodes(uri)): Promise { const { document } = this try { return await resolveReference(symbol, this.imports, { uri: document.uri.toString(), symbols, definition: async (offset) => { const [found] = await languageDefinitions(document.uri, document.positionAt(offset)) if (!found) return undefined const doc = await workspace.openTextDocument(found.uri) return { uri: found.uri.toString(), selection: { startIndex: doc.offsetAt(found.range.start), endIndex: doc.offsetAt(found.range.end) } } }, }) } catch (err) { logger.add({ level: 'warn', source: 'codeReader', message: `没能找注释里的引用 [${symbol}]:${err instanceof Error ? err.message : String(err)}` }) return undefined } } /** 跳到链接指的符号:在这份文件里就在本预览里滚过去、闪一下;别的文件同 ⌘ + 点击跳到定义(见 openElsewhere)。 */ private async showLinkTarget({ uri, selection }: LinkTarget): Promise { if (uri === this.document.uri.toString()) { this.reveal({ line: this.document.positionAt(selection.startIndex).line, definition: selection.startIndex }) return } const doc = await workspace.openTextDocument(Uri.parse(uri)) await this.openElsewhere(doc.uri, { range: new Range(doc.positionAt(selection.startIndex), doc.positionAt(selection.endIndex)), offset: selection.startIndex }) } private markRepoLinkBroken(path: string | undefined, symbol?: string): void { if (this.ready) this.post({ type: 'repoLinkBroken', ...(path !== undefined && { path }), ...(symbol !== undefined && { symbol }) }) } private markRepoLinkOpened(path: string | undefined, symbol?: string): void { if (this.ready) this.post({ type: 'repoLinkOpened', ...(path !== undefined && { path }), ...(symbol !== undefined && { symbol }) }) } /** * 重写 anchor 那块的注释:按最近一次发出去的块树找到它,记下它现在的样子(写回前比对),把发给模型的话和写回的办法交给 CodeQa。 * 点按钮(没有 request)发技能命令、开新会话;输入框里说的话接着这块最近一次重写的会话说,带上块现在在哪。 * 找不到这块、文件有没保存的修改(模型读的是磁盘上的文件)、开不了时,直接回一条失败。 */ private async rewrite(askId: string, anchor: string, request: string | undefined, images: ClaudeImage[]): Promise { const fail = (error: string): void => this.post({ type: 'rewriteEnd', askId, status: 'failed', error }) const { document } = this const block = this.sent && findBlock(this.sent.blocks, anchor) const job = block?.commentTarget && commentJob(block.commentTarget, block.startLine, block.endLine, line => document.lineAt(line).text, () => document.lineCount) if (!block || !job) { fail(block && !block.commentTarget ? '这块不能写注释。' : '这块代码刚改过,找不到了。等预览刷新后再重写。') return } const at = locate(document.uri) if (!at) { fail('这个文件不在工作区文件夹里,重写记录没地方存。') return } if (document.isDirty) { fail('文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。') return } const prompt = commentRewritePrompt(at.key, block.startLine, block.endLine, request) const question = request ?? prompt const refused = await this.owner.qa.rewrite(document.uri, { askId, anchor, question, prompt, ...(request !== undefined && { followUp: commentFollowUp(at.key, block.startLine, block.endLine, request) }), images, writeBack: body => this.writeBack(job, body), }) if (refused !== undefined) { fail(refused) return } // 页面点按钮时还不知道那句技能命令,先报上去 this.post({ type: 'rewriteProgress', askId, question, text: '' }) this.postQa() } /** 把交回的正文写回这份文件;预览关了也照样按 uri 打开文档来写。 */ private async writeBack(job: CommentJob, body: string | undefined): Promise { const document = await workspace.openTextDocument(this.document.uri) return writeCommentBody(job, body, { lineAt: line => document.lineAt(line).text, lineCount: () => document.lineCount, write: edit => writeCommentEdit(document, edit), }) } } /** 写回重写好的注释:换掉原来那几行(同块编辑),或在块上面插进新写的几行。 */ async function writeCommentEdit(document: TextDocument, edit: CommentEdit): Promise { if (edit.kind === 'replace') return replaceDocumentLines(document, edit.startLine, edit.endLine, edit.text) if (edit.line >= document.lineCount) return 'missing' const eol = document.eol === EndOfLine.CRLF ? '\r\n' : '\n' const change = new WorkspaceEdit() change.insert(document.uri, new Position(edit.line, 0), edit.text.split('\n').join(eol) + eol) if (!await workspace.applyEdit(change)) { void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`) return 'rejected' } return 'written' } /** 把这几行换成 [text](见 WriteResult):块编辑和重写注释都走这条。 */ async function replaceDocumentLines(document: TextDocument, startLine: number, endLine: number, text: string): Promise { const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text) if (!replacement) { logger.add({ level: 'warn', source: 'codeReader', message: `块内修改的行号 ${startLine}-${endLine} 已不在 ${document.uri.fsPath} 里,没有写回` }) return 'missing' } if (replacement.unchanged) return 'unchanged' const edit = new WorkspaceEdit() edit.replace(document.uri, new Range(replacement.startLine, 0, replacement.endLine, replacement.endCharacter), replacement.text) if (!await workspace.applyEdit(edit)) { void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`) return 'rejected' } return 'written' }