1080 lines
50 KiB
TypeScript
1080 lines
50 KiB
TypeScript
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'
|
||
}
|
||
}
|