Files
superwork/vscode/src/codeReader/editor/previewEditor.ts
T

1080 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange, WorkspaceIndex } 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 { IndexedEntry } from '../workspaceIndex'
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 { dirname } from 'node:path'
import { commands, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
import { webviewHtml, webviewUiRoot } from '../../webview/html'
import { DocumentCallees } from '../calleeKinds'
import { commentRewritePrompt, commentSource, findPlaced, modelCommentBody, refillComment } 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 { askClaude, askFailure } from '../qa/claudeAsk'
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<void>
}
/**
* 「代码阅读预览」自定义编辑器。预览和普通编辑器共用同一个 TextDocument:
* 块内修改走 WorkspaceEdit,未保存标记、⌘S、⌘Z 都是 VS Code 原生行为。
*/
export class PreviewEditorProvider implements CustomTextEditorProvider {
private readonly previews = new Set<Preview>()
/** 侧栏「问 AI」打开的预览,解析时把光标放进顶部输入框;已经开着的由 openAsking 自己聚焦 */
private readonly pathFocus = new Set<string>()
/** 按文档 uri:等着交给马上要打开的新预览的位置(从文本编辑器切过来时它顶部那一行,或跳到的定义) */
private readonly reveals = new Map<string, Reveal>()
/** 哪个预览眼下能用键盘在高亮的各处出现之间跳,跟着它设快捷键 when 子句用的上下文键 */
readonly navigation = new NavigationContext<Preview>(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<RuleLocation[]>,
/** 问 AI:各预览共用,同一份文件开在几处时问答、正在答的问题都一样 */
readonly qa: CodeQa,
/** 工作区里别的文件定义的、原名叫 name 的中文名(languageId 是查的这份文档的语言) */
readonly indexedNames: (languageId: string, name: string) => Promise<IndexedName[]>,
/** 库规则的工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts) */
readonly indexed: (index: WorkspaceIndex, key: string) => Promise<IndexedEntry[]>,
/** 预览标差异时对比什么 */
readonly diffBases: DiffBases,
/** 哪些文件是项目写明的入口、预览里哪些名字的块标成入口 */
readonly entries: Pick<WorkspaceEntries, 'namesOf' | 'onDidChange'>,
/** 在代码阅读侧栏的树上定位一个目录(仓库链接指到目录时);树上没有它时返回 false */
readonly revealInTree: (uri: Uri) => Promise<boolean>,
) {}
/** 侧栏「问 AI」打开预览,并把光标放进顶部输入框。已经开着的就揭到前面再聚焦。 */
async openAsking(uri: Uri): Promise<void> {
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<unknown>): Promise<void> {
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
}
}
/** 一段注释重写的结果:`done` 已经写进文件,`failed` 没写并带给人看的原因,`stopped` 是被叫停、没写。 */
type RewriteOutcome
= | { status: 'done' }
| { status: 'stopped' }
| { status: 'failed', error: string }
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<FoundLocation>
/** webview 报来的视口顶部那块的起始行 */
visibleLine: number | undefined
/** 正在重写的注释(块的记号)。同一块没写完不再开一次 */
private readonly rewriting = new Set<string>()
/** 预览关掉时停掉还在跑的重写 */
private readonly rewriteAbort = new AbortController()
/** 每段正在重写的注释各自的停止开关;预览关掉时 [rewriteAbort] 会把它们都停掉 */
private readonly rewriteStops = new Map<string, AbortController>()
/** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */
private sent: SentDocument | undefined
/** 最近一次发出去的文档里语言插件自己认得出的名字(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()
}),
// 页面没 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()
}),
]
}
get languageId(): string {
return this.document.languageId
}
get uri(): Uri {
return this.document.uri
}
get active(): boolean {
return this.panel.active
}
async push(): Promise<void> {
clearTimeout(this.timer)
if (!this.ready)
return
const request = ++this.latest
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))
}
}
/**
* 对比基准里的旧版本排成视图数据:和这份文档同一种语言、同一个翻译插件和库规则;规则查定义、调用的种类用这份文档眼下查到的答案,
* 两边同样的写法翻得一样,不会因为答案晚到被当成改了;项目入口也按眼下的标,两边的入口块一样。旧版本里规则出的错不记(记的是现在这份的)。
*/
private async readOld(source: string): Promise<OldDocument | undefined> {
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<BlockKind | null | undefined> {
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<Occurrence[]> {
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<DefinitionStart[]> {
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.rewriteAbort.abort()
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<void> {
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<void> {
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<void> {
switch (msg.type) {
case 'ready':
this.ready = true
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
this.post({ type: 'filter', ...this.filter.current })
this.postHoverDelay()
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':
await this.owner.qa.profiles.select(msg.id)
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 this.applyEdit(msg.startLine, msg.endLine, msg.text)
return
case 'rewriteComment':
await this.rewriteComment(msg.anchor)
return
case 'stopRewrite':
this.rewriteStops.get(msg.anchor)?.abort()
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 'occurrenceNavigation':
this.owner.navigation.update(this, msg.available)
}
}
/** 回答 webview 第 id 次询问:offset 处的名字在这份文档里出现在哪几处。下标属于别的文档版本时不答,webview 换了文档版本会清掉高亮 */
private async findOccurrences(id: number, version: number, offset: number): Promise<void> {
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<void> {
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<void> {
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<number>('delay', 300), hidingDelay: hover.get<number>('hidingDelay', 300) })
}
/**
* 回答 webview 第 id 次要定义卡片:offset 处名字的第一处定义所在那块(见 peekAt);指着的名字就是定义本身时没有。
* 下标属于别的文档版本时不答,webview 换了文档版本会收起浮窗
*/
private async peekDefinition(id: number, version: number, offset: number): Promise<void> {
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<void> {
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<DefinitionPeek | undefined> {
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<void> {
if (this.panel.active && !workspace.getConfiguration('workbench.editor').get<boolean>('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<unknown> => 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 checkRepoLinks(id: number, paths: string[], symbols: string[]): Promise<void> {
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)
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<void> {
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<void> {
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<void> {
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<LinkTarget | undefined> {
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<LinkTarget | undefined> {
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<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> {
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<void> {
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 }) })
}
/** 用写注释的技能重写这一段,只把正文套回原来的定界符,再走块编辑那条写回。 */
private async rewriteComment(anchor: string): Promise<void> {
if (this.rewriting.has(anchor) || this.rewriteAbort.signal.aborted)
return
this.rewriting.add(anchor)
const stop = new AbortController()
const onDispose = (): void => stop.abort()
this.rewriteAbort.signal.addEventListener('abort', onDispose)
this.rewriteStops.set(anchor, stop)
try {
const outcome = await this.rewriteCommentNow(anchor, stop.signal)
if (!this.rewriteAbort.signal.aborted)
this.post({ type: 'rewriteEnd', anchor, status: outcome.status, ...(outcome.status === 'failed' && { error: outcome.error }) })
}
finally {
this.rewriteAbort.signal.removeEventListener('abort', onDispose)
this.rewriteStops.delete(anchor)
this.rewriting.delete(anchor)
}
}
private failRewrite(message: string): void {
void window.showErrorMessage(`代码阅读:${message}`)
}
private async rewriteCommentNow(anchor: string, signal: AbortSignal): Promise<RewriteOutcome> {
const failed = (message: string): RewriteOutcome => {
this.failRewrite(message)
return { status: 'failed', error: message }
}
const found = this.sent && findPlaced(this.sent.blocks, anchor)
if (!found)
return failed('这块注释刚改过,找不到了。等预览刷新后再重写。')
const { block, siblings, index } = found
const startLine = block.commentStartLine
const endLine = block.commentEndLine
if (startLine === undefined || endLine === undefined)
return failed('这块没有能重写的注释。')
const { document } = this
if (startLine < 0 || endLine < startLine || endLine >= document.lineCount)
return failed('这块注释的位置已经变了,没有写回。')
const linesOf = (from: number, to: number): string => Array.from({ length: to - from + 1 }, (_, i) => document.lineAt(from + i).text).join('\n')
const raw = linesOf(startLine, endLine).split('\n')
const snapshot = raw.join('\n')
const comment = commentSource(raw)
const source = document.getText()
const attached = startLine !== block.startLine || endLine !== block.endLine
? [
...(startLine > block.startLine ? [linesOf(block.startLine, startLine - 1)] : []),
...(endLine < block.endLine ? [linesOf(endLine + 1, block.endLine)] : []),
].filter(part => part !== '').join('\n')
: undefined
const next = siblings[index + 1]
const following = attached === undefined && next && next.startLine >= 0 && next.endLine >= next.startLine && next.endLine < document.lineCount
? linesOf(next.startLine, next.endLine)
: undefined
const prompt = commentRewritePrompt({
comment,
source,
startLine,
endLine,
earlierKinds: siblings.slice(0, index).map(item => item.kind),
...(following !== undefined && { following }),
...(attached !== undefined && attached !== '' && { attached }),
})
if (!prompt)
return failed('这段注释的定界符认不出来,没有改文件。')
const at = locate(document.uri)
let text = ''
let activity: string | undefined
const publish = (): void => {
this.post({
type: 'rewriteProgress',
anchor,
question: prompt,
text,
...(activity !== undefined && { activity }),
})
}
publish()
const profile = await this.owner.qa.profiles.current()
if (signal.aborted)
return { status: 'stopped' }
let answerText: string
try {
const answer = await askClaude({
prompt,
images: [],
...(profile && { profilePath: profile.path }),
cwd: at?.folder.uri.fsPath ?? dirname(document.uri.fsPath),
signal,
onEvent: (event) => {
if (event.kind === 'text') {
text = event.text
activity = undefined
}
else if (event.kind === 'activity') {
activity = event.label
}
else {
return
}
publish()
},
})
answerText = answer.text
}
catch (err) {
if (signal.aborted)
return { status: 'stopped' }
return failed(askFailure(err))
}
if (signal.aborted)
return { status: 'stopped' }
const body = modelCommentBody(answerText)
if (!body)
return failed('模型没有给出注释正文,没有改文件。')
if (endLine >= document.lineCount)
return failed('这段注释在重写期间变了,没有写回。')
const current = linesOf(startLine, endLine)
if (current !== snapshot)
return failed('这段注释在重写期间变了,没有写回。')
const filled = refillComment(comment, body)
if (!filled)
return failed('这段注释的定界符认不出来,没有改文件。')
if (signal.aborted)
return { status: 'stopped' }
const wrote = await this.applyEdit(startLine, endLine, filled)
if (wrote === 'written' || wrote === 'unchanged')
return { status: 'done' }
if (wrote === 'rejected')
return { status: 'failed', error: `没能把修改写回 ${workspace.asRelativePath(document.uri)}` }
return failed('这段注释在重写期间变了,没有写回。')
}
/** 把这几行换成 [text]。`written` 写进去了,`unchanged` 跟文件里一样不用写,`missing` 行号已经不在,`rejected` 编辑器没接受。 */
private async applyEdit(startLine: number, endLine: number, text: string): Promise<'written' | 'unchanged' | 'missing' | 'rejected'> {
const { document } = this
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'
}
}