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

1250 lines
63 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, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { BlockView } from '../blockView'
import type { CommentEdit, CommentJob, WriteResult } from '../commentRewrite'
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange } from '../contract'
import type { OpenedFrom, PreviewDiffBases } from '../diff/diffBase'
import type { OldDocument, SentDocument } from '../diff/previewDiff'
import type { WorkspaceEntries } from '../entries/workspaceEntries'
import type { DefinitionStart, IndexedName, NameSources, ShownName } from '../nameResolution'
import type { CodeQa, TopRequest } from '../qa/codeQa'
import type { CodeReaderRegistry } from '../registry'
import type { LookupSources } from '../ruleLookups'
import type { TreeSitterRuntime } from '../runtime'
import type { ImportedName, LinkTarget, ReferenceTarget } from '../symbolLinks'
import type { ThemeColors } from '../theme/codeColors'
import type { IndexFind } from '../workspaceIndex'
import type { BlockFilter } from './blockFilter'
import type { FoundLocation } from './definitions'
import type { PeekRead } from './documentReads'
import type { ChatKind, DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, SymbolReference, WebviewToExtension } from './messages'
import type { TargetSpot } from './openTarget'
import type { LinkBase, TopSubject } from './pathPreview'
import type { RepoEntry } from './repoPath'
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 { DefinitionKinds } from '../definitionKinds'
import { PreviewDiff } from '../diff/previewDiff'
import { blockReplacement } from '../editText'
import { locate, locateQa } from '../folderStore'
import { LanguageRequests } from '../languageRequests'
import { definitionFor, symbolAt, symbolOccurrences } from '../localSymbols'
import { DocumentNames, resolveNames } from '../nameResolution'
import { NOT_REWRITABLE } from '../qa/codeQa'
import { PATH_ASK_ANCHOR } from '../qa/pathQa'
import { askPrompt, followUpPrompt } from '../qa/prompt'
import { DocumentLookups } from '../ruleLookups'
import { outsideUses } from '../symbolLinks'
import { definitionAction } from './definitionAction'
import { definitionSiteNames, namesOnCard, pagePeekBlock, peekBlock } from './definitionPeek'
import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
import { DefinitionSources } from './definitionSources'
import { documentMessage, pageDocument } from './documentMessage'
import { DocumentReads } from './documentReads'
import { documentSymbolNodes } from './documentSymbols'
import { FIND_CONTEXT } from './findCommands'
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
import { occurrencesOf } from './occurrences'
import { languageOf, linesSpot, openingFor, revealOf } from './openTarget'
import { linkBaseOf, PATH_PREVIEW_SCHEME, topSubjectOf } from './pathPreview'
import { ReferenceTargets } from './referenceTargets'
import { findRepoSymbol, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath'
export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
/** 连续输入时合并成一次重新解析。 */
const REFRESH_DELAY_MS = 150
/** 预览里各有一个 profile 下拉的两种对话 */
const CHAT_KINDS: readonly ChatKind[] = ['ask', 'rewrite']
/**
* 一次打开预览带的话:这个标签页是从哪儿打开的(定对比什么);要滚到哪(从文本编辑器切过来时它顶部那一行,或跳到的定义);
* 要不要把光标放进顶部「问 AI」输入框(侧栏「问 AI」打开的)。
*
* 只有侧栏各栏点开文件、「编辑」切回「预览」带 `from`;跳定义、点链接、问 AI 这些只是去看一眼,不带:新开的预览不比,已经开着的照旧。
*/
export interface OpenRequest {
from?: OpenedFrom
reveal?: Reveal
focusPath?: boolean
}
/**
* 「代码阅读预览」自定义编辑器。预览和普通编辑器共用同一个 TextDocument:
* 块内修改走 WorkspaceEdit,未保存标记、⌘S、⌘Z 都是 VS Code 原生行为。
*/
export class PreviewEditorProvider implements CustomTextEditorProvider {
private readonly previews = new Set<Preview>()
/** 按文档 uri:等着交给马上要打开的新预览的打开请求(见 [open]) */
private readonly requests = new Map<string, OpenRequest>()
/** 哪个预览眼下能用键盘在高亮的各处出现之间跳,跟着它设快捷键 when 子句用的上下文键 */
readonly navigation = new NavigationContext<Preview>(value => void commands.executeCommand('setContext', OCCURRENCES_CONTEXT, value))
/** 哪个预览眼下查找栏开着、有焦点,跟着它设查找下一处 / 上一处快捷键 when 子句用的上下文键 */
readonly finding = new NavigationContext<Preview>(value => void commands.executeCommand('setContext', FIND_CONTEXT, value))
/** 调用的名字的定义是哪种块:各预览共用,定义所在的文件同一版只解析一次 */
readonly definitionKinds: DefinitionKinds
/** 悬停看别的文件里的定义时那份文件读成的文档:各预览共用,同一版不再重新解析 */
private readonly documentReads: DocumentReads
/** 别的文件的注释里不带路径的引用解析过的指到哪,一份文件一套(见 [referenceTargets]):各预览共用,文件关掉、没人再用时跟着丢 */
private readonly elsewhereReferences = new WeakMap<TextDocument, ReferenceTargets>()
constructor(
private readonly extensionUri: Uri,
private readonly registry: CodeReaderRegistry,
private readonly runtime: TreeSitterRuntime,
private readonly filter: BlockFilter,
/** 代码配色:各预览共用当前颜色主题的那一份,换了主题、改了代码配色的设置时发给订阅的 */
readonly codeColors: Pick<ThemeColors, 'current' | 'subscribe'>,
/** 库规则标的引用去哪儿查定义(交给标它的规则) */
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: IndexFind,
/** 预览标差异时对比什么:看文件在哪、这个标签页是从哪儿打开的,基准变了发 onDidChange */
readonly diffBases: PreviewDiffBases,
/** 哪些文件是项目写明的入口、预览里哪些名字的块标成入口 */
readonly entries: Pick<WorkspaceEntries, 'namesOf' | 'onDidChange'>,
/** 在代码阅读侧栏的树上定位一个目录(仓库链接指到目录时);树上没有它时返回 false */
readonly revealInTree: (uri: Uri) => Promise<boolean>,
/** 「了解一块代码」的对话页里接着说(subject 是那个功能,见 featureSubjectUri);说不了时返回给人看的原因 */
readonly askFeature: (subject: Uri, request: TopRequest) => Promise<string | undefined>,
) {
this.definitionKinds = new DefinitionKinds(languageId => registry.languageFor(languageId), runtime)
this.documentReads = new DocumentReads(registry, runtime)
}
/**
* 注册成预览编辑器。标签页切走时页面留着(`retainContextWhenHidden`):切回来不用重新生成、重发文档(切走期间改过的才补一次),页面也不用整份重画,
* 代价是每个开着的预览切走后仍占着它那个页面的内存。
*/
register(): Disposable {
return window.registerCustomEditorProvider(PREVIEW_VIEW_TYPE, this, { webviewOptions: { retainContextWhenHidden: true } })
}
/** 别的文件 `document` 按它的语言读成的文档(见 [DocumentReads]):悬停看定义时卡片画它的块,卡片里注释中不带路径的引用按它找。 */
readElsewhere(document: TextDocument): Promise<PeekRead> {
const { uri, languageId, version, fileName } = document
return this.documentReads.read(uri.toString(), { languageId, version, getText: () => document.getText(), fileName, entryNames: this.entries.namesOf(uri) })
}
/** 别的文件 `document` 的注释里不带路径的引用 `reference` 指到哪:按那份文件读成的文档找,和它自己的预览找的一样(见 [referenceTargets])。 */
async resolveElsewhere(document: TextDocument, reference: SymbolReference): Promise<ReferenceTarget> {
const { references } = await this.readElsewhere(document)
let targets = this.elsewhereReferences.get(document)
if (!targets) {
targets = referenceTargets(document, this.registry)
this.elsewhereReferences.set(document, targets)
}
return targets.resolve(references, reference)
}
/**
* 用 `show` 打开 `uri` 的预览,把 `request` 交给它:新建的预览解析时带走;`show` 只是切到了已经开着的预览(没有新建)时,交给切到的那个。
*
* `show` 默认按「预览标签」打开,同 `vscode.open`(用户关了 `workbench.editor.enablePreview` 时 VS Code 自会固定)。
*/
async open(uri: Uri, request: OpenRequest, show: () => Thenable<unknown> = () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true })): Promise<void> {
const key = uri.toString()
this.requests.set(key, request)
let claimed: boolean
try {
await show()
claimed = this.requests.get(key) !== request
}
finally {
// 打开命令等新预览解析完才返回;没打开成时也不能把这次请求留给以后别处的打开
if (this.requests.get(key) === request)
this.requests.delete(key)
}
if (!claimed)
[...this.previews].find(p => p.uri.toString() === key && p.active)?.reopen(request)
}
/**
* 新开一个预览:先接下给它的打开请求、接上消息,再装页面。页面能读的本地目录(见 [readableRoots])装之前一次定好,之后不再改:
* 改了 webview 的选项 VS Code 会重新加载页面,打开时要滚到的那处就丢了。
*/
async resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): Promise<void> {
const key = document.uri.toString()
const request = this.requests.get(key)
this.requests.delete(key)
const preview = new Preview(document, panel, this, this.registry, this.runtime, this.filter, request)
this.previews.add(preview)
let disposed = false
panel.onDidDispose(() => {
disposed = true
this.previews.delete(preview)
this.navigation.update(preview, false)
this.finding.update(preview, false)
preview.dispose()
})
const roots = await this.readableRoots(document.uri)
// 找目录的工夫标签页已经关了
if (disposed)
return
panel.webview.options = { enableScripts: true, localResourceRoots: roots }
panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html')
}
/**
* 预览页面能读的本地目录:页面自己的脚本和样式;问答带的图片所在的目录(各工作区文件夹的 `.spx/code-qa/`,见 [CodeQa.imageRoots]),
* 历史和定义卡片里才画得出缩略图;注释、回答里的仓库图片所在的各个根(见 [linkBaseOf]),根的真实位置和这个路径不一样时两个都放,读不到真实位置就只放这个路径。
*/
private async readableRoots(uri: Uri): Promise<Uri[]> {
const { roots } = await linkBaseOf(uri)
const repoRoots = await Promise.all(roots.map(async root => [root, await realpath(root).catch(() => root)]))
return [webviewUiRoot(this.extensionUri), ...this.qa.imageRoots(), ...[...new Set(repoRoots.flat())].map(root => Uri.file(root))]
}
/** 「跳到下一处 / 上一处高亮」:发给能跳的那个预览,从命令面板运行时发给活动的预览。 */
moveOccurrence(step: 1 | -1): void {
this.navigation.target(this.previews, preview => preview.active)?.moveOccurrence(step)
}
/** 「查找」:打开查找栏。发给查找栏开着、有焦点的那个预览,没有就发给活动的预览。 */
find(): void {
this.finding.target(this.previews, preview => preview.active)?.find()
}
/** 「查找下一处 / 上一处」:发给查找栏开着、有焦点的那个预览,从命令面板运行时发给活动的预览。 */
moveFind(step: 1 | -1): void {
this.finding.target(this.previews, preview => preview.active)?.moveFind(step)
}
/** 让已打开的预览按当前注册表重发;给了 languageId 就只刷这种语言的。 */
refresh(languageId?: string): void {
for (const preview of this.previews) {
if (languageId === undefined || preview.languageId === languageId)
void preview.push()
}
}
/** uri 的预览视口顶部那块的起始行;几个编辑器组里都开着这个文件的预览时取活动的那个。 */
visibleLine(uri: Uri): number | undefined {
return this.shown(uri)?.visibleLine
}
/** uri 的预览这个标签页是从哪儿打开的;几个编辑器组里都开着时取活动的那个,页面还没准备好、不知道的没有。 */
openedFrom(uri: Uri): OpenedFrom | undefined {
return this.shown(uri)?.openedFrom
}
/** uri 的预览:几个编辑器组里都开着时取活动的那个。 */
private shown(uri: Uri): Preview | undefined {
const key = uri.toString()
const previews = [...this.previews].filter(p => p.uri.toString() === key)
return previews.find(p => p.active) ?? previews[0]
}
}
class Preview {
/**
* 页面发过 ready(装好了、能收消息)之后才发消息。标签页切走时页面留着,照常收消息;
* 页面真的重新加载了(webview 进程崩了重建、开发者重载 webview)会再发 ready,那时整份重发
*/
private ready = false
/** 切走期间该重新生成的文档没生成(文件改了、规则变了):重新看得见时补一次;没过期的切回来什么都不用做 */
private outdated = false
/** 页面上那份文档的版本:还不是眼下这一版时(正在生成、切走期间改过),要滚到的位置等新的那份一起带过去 */
private shown: number | undefined
/** 侧栏「问 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
/** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */
private sent: SentDocument | undefined
/** 发文档后在后台问语言扩展的请求(中文名、库规则查的定义、调用的种类)排队发:在途的有上限,离视口近的先发,文档改了排着的作废 */
private readonly requests = new LanguageRequests(() => this.document.version, () => this.visibleLine)
/** 能看调用树的块(函数、类、接口卡片)落在几棵调用树里。文档发出去之后再数,不挡住第一次预览,离视口近的先数;同一版文档数过的页面重新加载后接着用 */
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),
}
},
offset => this.requests.distance(this.document.positionAt(offset).line),
)
/** 这份文档的注释里不带路径的引用解析过的指到哪(见 [referenceTargets]) */
private readonly references: ReferenceTargets
/** 最近一次发出去的文档里语言插件自己认得出的名字(LanguageAdapter.symbols):语言扩展查不到定义、各处出现时用 */
private symbols: { version: number, list: LocalSymbol[] } | undefined
/** 最近一次发出去的文档里各条导入引进来的名字:注释里不带路径的引用按它找导入的定义 */
private imports: ImportedName[] = []
/** 最近一次发出去的文档里,代码用到、本文件又没定义的名字各在哪几处(见 [outsideUses]):注释里的引用按它找语言自带的名字的定义 */
private uses = new Map<string, SourceRange[]>()
/** 这份文档各版本显示成中文名的名字 */
private readonly names: DocumentNames
/** 库规则问的名字的定义查到了什么:查到的变了就重发文档 */
private readonly lookups: DocumentLookups
/** 调用的名字定义成哪种块(分新建还是调用):查到的变了就重发文档 */
private readonly callees: DocumentCallees
/** 这份文件相比对比基准改了什么:发完文档再算、补发 */
private readonly diff: PreviewDiff
/**
* 随下一份真正发出去的文档走、只走一次:新预览第一份文档要滚到的位置,或页面上还不是眼下这一版文档时来的 reveal。
* 之后页面真的重新加载了,按 webview 自己记的位置恢复
*/
private pendingReveal: Reveal | undefined
/** 这个标签页是从哪儿打开的,定对比什么;打开请求没说的(跳定义新开的、VS Code 重启后恢复的)先空着,页面 ready 时按它记着的定 */
private from: OpenedFrom | undefined
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,
/** 打开它的那次请求;VS Code 重启后恢复的、VS Code 自己换编辑器打开的没有 */
request: OpenRequest | undefined,
) {
this.from = request?.from
this.pendingReveal = request?.reveal
this.pendingPathFocus = request?.focusPath ?? false
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.references = referenceTargets(document, registry)
this.names = new DocumentNames((version, names) => {
if (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(
() => this.from === undefined ? undefined : owner.diffBases.diffBase(document.uri, this.from),
source => this.readOld(source),
() => this.oldInputs(),
diff => this.post({ type: 'diff', diff }),
text => logger.add({ level: 'warn', source: 'codeReader', message: `${document.uri.fsPath}:${text}` }),
)
this.disposables = [
owner.diffBases.onDidChange(() => {
if (this.sent)
void this.diff.update(this.sent)
}),
panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)),
panel.onDidChangeViewState(() => {
if (panel.visible && this.outdated)
void this.push()
}),
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 })
}),
// 同上:没 ready 的等 ready 时在第一份文档之前发眼下的
owner.codeColors.subscribe((colors) => {
if (this.ready)
this.post({ type: 'codeColors', ...colors })
}),
// 项目的入口变了:重新发文档,卡片左边的入口图标跟着变
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()
}),
...CHAT_KINDS.map(kind => owner.qa.profiles[kind].subscribe((choice) => {
if (this.ready)
this.post({ type: 'profiles', kind, ...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, queued } = event.ask
this.post({ type: 'rewriteProgress', askId, question, text, ...(activity !== undefined && { activity }), ...(queued && { queued }) })
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
}
get openedFrom(): OpenedFrom | undefined {
return this.from
}
async push(): Promise<void> {
clearTimeout(this.timer)
if (!this.ready)
return
// 切走的预览不在后台生成,后面跟着文档的查询也不起;分屏同时看得见的照常马上生成
if (!this.panel.visible) {
this.outdated = true
return
}
this.outdated = false
const request = ++this.latest
// 路径页、功能对话页:文档是空的,只在顶部聊天
if (this.document.uri.scheme === PATH_PREVIEW_SCHEME) {
this.sent = undefined
const reveal = this.pendingReveal
this.pendingReveal = undefined
this.shown = this.document.version
this.post({
type: 'document',
version: this.document.version,
languageId: '',
blocks: [],
source: '',
colors: { roles: [], runs: [] },
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, kinds, bindings } = 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)
return
this.definitions.remember(message.version, message.blocks)
// 调用的定义就在这份文档里时,认种类直接用这一遍读出的,不再解析一遍
this.owner.definitionKinds.remember(this.document.uri.toString(), version, kinds)
// 解析失败的文档没有块:问 AI 找不到块,也不拿它比差异
this.sent = message.error === undefined ? { version: message.version, blocks: message.blocks, source } : undefined
this.symbols = { version, list: symbols }
this.imports = imports
this.uses = outsideUses(bindings, source)
const reveal = this.pendingReveal
this.pendingReveal = undefined
this.shown = version
this.post({ ...pageDocument(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, bindings, this.nameSources(version), report))
this.lookups.resolve(version, session.requests(), this.lookupSources(version))
this.callees.resolve(version, callees.requests(), offset => this.queued(version, offset, () => this.calleeKind(offset), undefined))
this.callTrees.documentSent()
}
}
/**
* 对比基准里的旧版本排成视图数据:和这份文档同一种语言、同一个翻译插件和库规则;规则查定义、调用的种类用这份文档眼下查到的答案,
* 两边同样的写法翻得一样,不会因为答案晚到被当成改了;项目入口也按眼下的标,两边的入口块一样。旧版本里规则出的错不记(记的是现在这份的)。
*/
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
}
/** 排旧版本除了旧内容还看的(见 readOld):选的翻译插件、项目入口、规则查到的定义和调用的种类变过几次。变了旧版本要重新解析 */
private oldInputs(): string {
return JSON.stringify([this.registry.selectedTranslator(this.document.languageId)?.id, this.owner.entries.namesOf(this.document.uri), this.lookups.revision, this.callees.revision])
}
/** 这份文档里 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, this.owner.definitionKinds)
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 }))
}
/** 找 version 这一版的中文名落在哪些名字上时问的:这份文档的语言扩展(排队问)、工作区里的中文名索引、页面的视口。 */
private nameSources(version: number): NameSources {
const { document } = this
return {
uri: document.uri.toString(),
occurrences: offset => this.queued(version, offset, async () => (await this.occurrences(offset)).map(({ start, end }) => ({ start, end })), []),
definitions: offset => this.queued(version, offset, () => this.definitionStarts(offset), []),
indexed: name => this.owner.indexedNames(document.languageId, name),
visible: offset => this.requests.visible(document.positionAt(offset).line),
}
}
/** 查 version 这一版库规则问的名字时问的:规则的工作区索引、这份文档的语言扩展(排队问)。 */
private lookupSources(version: number): LookupSources {
return {
find: (index, key) => this.owner.indexed(index, key),
definitions: offset => this.queued(version, offset, () => this.definitionStarts(offset), []),
}
}
/** 排进后台请求的队(见 LanguageRequests):version 这一版文档 offset 处要问的,没发就作废的交回 dropped(当语言扩展没答)。 */
private queued<T>(version: number, offset: number, request: () => Promise<T>, dropped: T): Promise<T> {
return this.requests.run(version, this.document.positionAt(offset).line, request, dropped)
}
/** 跳到高亮着的名字的下一处 / 上一处出现;页面还没 ready 就算了(还没有高亮)。 */
moveOccurrence(step: 1 | -1): void {
if (this.ready)
this.post({ type: 'moveOccurrence', step })
}
/** 打开查找栏;页面还没 ready 就算了。 */
find(): void {
if (this.ready)
this.post({ type: 'openFind' })
}
/** 查找栏里跳到下一处 / 上一处命中;页面还没 ready 就算了(还没有查找栏)。 */
moveFind(step: 1 | -1): void {
if (this.ready)
this.post({ type: 'moveFind', step })
}
/** 开着时又被点开(见 [PreviewEditorProvider.open]):这次说了从哪儿打开的就按它重新比,没说的照旧;要滚到哪、要不要聚焦输入框照这次的。 */
reopen(request: OpenRequest): void {
if (request.from !== undefined) {
this.from = request.from
if (this.ready) {
this.post({ type: 'openedFrom', from: request.from })
if (this.sent)
void this.diff.update(this.sent)
}
}
if (request.reveal)
this.reveal(request.reveal)
if (request.focusPath)
this.focusPathAsk()
}
/** 滚到 reveal:页面上已是眼下这一版文档就直接发;还不是(还没 ready、正在生成、切走期间改过)就随新的那份文档带过去。 */
private reveal(reveal: Reveal): void {
if (this.shown === this.document.version)
this.post({ type: 'reveal', ...reveal })
else
this.pendingReveal = reveal
}
dispose(): void {
this.callTrees.dispose()
this.requests.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.webviewUrl(file), subject.kind))
}
/** 本地文件 `file` 在这个预览页面里能直接用的地址(`webview.asWebviewUri`)。 */
private webviewUrl(file: Uri): string {
return this.panel.webview.asWebviewUri(file).toString()
}
/** 这一页顶部问的是什么(见 topSubjectOf)。 */
private subject(): { uri: Uri, kind: TopSubject } {
return topSubjectOf(this.document.uri)
}
/** 把光标放进顶部输入框;页面还没准备好就等 ready。 */
private focusPathAsk(): void {
if (!this.ready) {
this.pendingPathFocus = true
return
}
this.pendingPathFocus = false
this.post({ type: 'focusPathAsk' })
}
/** kind 那种对话的 profile 列表每次现读(同看板)。 */
private async postProfiles(kind: ChatKind): Promise<void> {
const choice = await this.owner.qa.profiles[kind].choice()
this.post({ type: 'profiles', kind, ...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 request = { askId, question, images }
const refused = subject.kind === 'feature'
? await this.owner.askFeature(subject.uri, request)
: await this.owner.qa.askPath(subject.uri, subject.kind, request)
if (refused !== undefined)
this.post({ type: 'answerEnd', askId, status: 'failed', error: refused })
else
this.postQa()
return
}
const at = locateQa(subject.uri)
const repoRoot = await repoRootOf(this.document.uri.fsPath)
const block = at && this.sent && { path: at.promptPath, languageId: this.document.languageId, blocks: this.sent.blocks, source: this.sent.source, anchor, ...(repoRoot !== undefined && { repoRoot }) }
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':
// 页面刚装好(新开的、VS Code 重启后恢复的、真的重新加载了的):眼下的状态和文档整份发一遍
this.ready = true
// 打开请求说了从哪儿打开的就按它;没说的按页面记着的(VS Code 重启后恢复的),页面也没记过的(新开的)不比
this.from ??= msg.openedFrom ?? 'other'
this.post({ type: 'openedFrom', from: this.from })
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
this.post({ type: 'filter', ...this.filter.current })
this.postHoverDelay()
this.postStickyScroll()
this.postQa()
for (const kind of CHAT_KINDS)
void this.postProfiles(kind)
// 也先于文档发:第一份文档一画出来就是主题的颜色,不会先没颜色再闪一下
this.post({ type: 'codeColors', ...await this.owner.codeColors.current() })
await this.push()
if (this.pendingPathFocus)
this.focusPathAsk()
return
case 'ask':
await this.ask(msg.askId, msg.anchor, msg.question, msg.images)
return
case 'selectProfile':
try {
await this.owner.qa.profiles[msg.kind].select(msg.id)
}
catch (err) {
void window.showErrorMessage(err instanceof Error ? err.message : String(err))
await this.postProfiles(msg.kind)
}
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, referenceOf(msg))
return
case 'findOccurrences':
await this.findOccurrences(msg.id, msg.version, msg.offset)
return
case 'openRepoLink':
await this.openRepoLink(msg)
return
case 'checkRepoLinks':
await this.checkRepoLinks(msg.id, msg.paths, msg.references)
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)
return
case 'findNavigation':
this.owner.finding.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.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.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) })
}
/** VS Code 的 Sticky Scroll 设置(按这份文件取,语言自己的设置优先):页面按它把滚出去一半的卡片钉在顶上。 */
private postStickyScroll(): void {
const sticky = workspace.getConfiguration('editor.stickyScroll', this.document)
this.post({ type: 'stickyScroll', enabled: sticky.get<boolean>('enabled', true), maxLineCount: sticky.get<number>('maxLineCount', 5) })
}
/**
* 回答 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.post({ type: 'definitionPeeked', id, ...(peek && { peek }) })
}
/** 回答 webview 第 id 次要注释里的链接指的符号的卡片:符号同点这个链接时找的那个(symbolTarget),找不到、是语言自带的名字时没有。 */
private async peekLink(id: number, rel: string | undefined, reference: SymbolReference): Promise<void> {
let peek: DefinitionPeek | undefined
try {
const target = await this.symbolTarget(rel, reference)
peek = target && target !== 'builtin' ? await this.peekAt(Uri.parse(target.uri), target.selection.startIndex) : undefined
}
catch (err) {
const link = rel === undefined ? `[${reference.symbol}]` : `${rel}#${reference.symbol}`
logger.add({ level: 'warn', source: 'codeReader', message: `没能找 ${link} 指的符号:${err instanceof Error ? err.message : String(err)}` })
}
this.post({ type: 'definitionPeeked', id, ...(peek && { peek }) })
}
/**
* `uri` 里 `offset` 处的定义的卡片:盖住它的那块([peekBlock])。同一份文件用最近发出去的那份文档的块和中文名;
* 别的文件按它的语言读成文档(翻译插件、库规则、项目入口同它自己的预览,规则查名字的定义、调用的种类不等),读过的各预览共用(见 [DocumentReads]),
* 中文名只认起了中文名的定义本身。语言没有代码阅读、解析失败、没有块盖住定义处时没有。
*/
private async peekAt(uri: Uri, offset: number): Promise<DefinitionPeek | undefined> {
try {
const target = await workspace.openTextDocument(uri)
const line = target.positionAt(offset).line
if (uri.toString() === this.document.uri.toString()) {
const { sent } = this
if (sent?.version !== target.version)
return undefined
const block = peekBlock(sent.blocks, offset, line)
return block && this.peekCard(uri, line, block, sent.source, namesOnCard(block, this.names.known(target.version)))
}
const { message, displayNames } = await this.owner.readElsewhere(target)
const block = message.error === undefined ? peekBlock(message.blocks, offset, line) : undefined
return block && this.peekCard(uri, line, block, message.source, namesOnCard(block, definitionSiteNames(displayNames)))
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `没能读出 ${uri.toString()} 里定义的那块:${err instanceof Error ? err.message : String(err)}` })
return undefined
}
}
/**
* `uri` 第 `line` 行的定义所在那块 `block`(`source` 是那份文件的源码)发给页面的卡片,带上 `names` 和这块自己下面问过 AI 的各次。
* 问答按那份文件和这块在那份文件块树里的记号(`block.anchor`,同那个文件的预览挂对话用的)找,子块上问的记号不同,不带。
*/
private peekCard(uri: Uri, line: number, block: BlockView, source: string, names: ShownName[]): DefinitionPeek {
const qa = this.owner.qa.blockThreads(uri, file => this.webviewUrl(file)).find(thread => thread.anchor === block.anchor)?.entries
return { path: workspace.asRelativePath(uri), uri: uri.toString(), line, block: pagePeekBlock(block, source), names, ...(qa && { qa }) }
}
/**
* 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开(新开的不比,已经开着的照旧),给了 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
}
await this.owner.open(uri, opening.reveal ? { reveal: opening.reveal } : {}, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options))
}
/** 点了调用树上的一个节点:打开它在的文件,滚到这一处(同 ⌘ + 点击跳到别的文件)。 */
private async goToCallNode(uri: Uri, position: Position): Promise<void> {
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)}` })
}
}
/**
* 预览里这批仓库路径(从哪儿算见 [linkBaseOf]),哪些文件或目录不在;这批不带路径的引用,各按写在哪份文件、哪块找(见 [resolveSymbolLink]),
* 哪些找不到、哪些是语言自带的名字(页面当普通文字)。
*/
private async checkRepoLinks(id: number, paths: string[], references: SymbolReference[]): Promise<void> {
const base = await linkBaseOf(this.document.uri)
const [missing, resolved] = await Promise.all([
Promise.all(paths.map(async rel => await linkEntry(base, rel) === undefined ? rel : undefined)),
Promise.all(references.map(async reference => ({ reference, target: await this.resolveSymbolLink(reference) }))),
])
this.post({
type: 'repoLinksChecked',
id,
missing: missing.filter(rel => rel !== undefined),
unresolved: resolved.filter(({ target }) => target === undefined).map(({ reference }) => reference),
builtin: resolved.filter(({ target }) => target === 'builtin').map(({ reference }) => reference),
})
}
/** 这批仓库图片哪些在。在的带回预览里能直接用的地址;不在、不是图片、跑出仓库的没有地址。 */
private async checkRepoImages(id: number, paths: string[]): Promise<void> {
const base = await linkBaseOf(this.document.uri)
const images = await Promise.all(paths.map(async (rel) => {
const place = isRepoImagePath(rel) ? base.place(rel) : undefined
if (!place)
return { path: rel }
const entry = await resolveRepoEntry(place.root, place.rel)
if (!entry || entry.isDirectory)
return { path: rel }
const src = this.webviewUrl(Uri.file(entry.fsPath))
return { path: rel, src }
}))
this.post({ type: 'repoImagesChecked', id, images })
}
/**
* 打开注释、问 AI 的回答里的仓库链接:目录在代码阅读侧栏的树上定位,文件用编辑器打开。
* 带了符号就按文档符号的名字跳过去,带了几行(`lines`)就跳到这几行(见 [linesSpot]);文件或符号没有就不打开。
* 没有路径的是不带路径的引用,按它写在的那份文件(`file`)、那块(`block`)找(见 [resolveSymbolLink]);它是语言自带的名字时不打开,告诉页面当普通文字。
*/
private async openRepoLink({ path: rel, symbol, block, file, lines }: Extract<WebviewToExtension, { type: 'openRepoLink' }>): Promise<void> {
const link = rel ?? `[${symbol}]`
try {
if (symbol !== undefined) {
const reference = referenceOf({ symbol, block, file })
const target = await this.symbolTarget(rel, reference)
if (target === 'builtin') {
this.post({ type: 'repoLinkBuiltin', ...reference })
return
}
if (!target) {
this.markRepoLinkBroken(rel, reference)
return
}
await this.showLinkTarget(target)
this.markRepoLinkOpened(rel, reference)
return
}
if (rel === undefined)
return
const entry = await linkEntry(await linkBaseOf(this.document.uri), 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
}
const spot = lines && await linesSpot(uri, lines)
await (spot ? this.showSpot(uri, spot) : 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)。文件没有、是目录、符号没有时没有;不带路径的引用是语言自带的名字时是 `'builtin'`。
* 点链接、悬停链接看定义卡片都按它找。
*/
private async symbolTarget(rel: string | undefined, reference: SymbolReference): Promise<ReferenceTarget> {
if (rel === undefined)
return this.resolveSymbolLink(reference)
const entry = await linkEntry(await linkBaseOf(this.document.uri), rel)
return entry && !entry.isDirectory ? this.locateRepoSymbol(Uri.file(entry.fsPath), reference.symbol) : undefined
}
/** 用文档符号按名字找(`Agent`、`Agent.owner_id`);没有这个名字就没有,不打开到文件顶上。 */
private async locateRepoSymbol(uri: Uri, symbol: string): Promise<LinkTarget | undefined> {
const found = findRepoSymbol(await documentSymbolNodes(uri.toString()), symbol)
return found && { uri: uri.toString(), selection: found.selection }
}
/**
* 注释里不带路径的引用(`[Name]`、`[Class.member]`)指到哪,检查坏链接、点击、悬停看定义卡片都走这里;解析过的记在 [ReferenceTargets] 里,不再问语言扩展。
*
* 按它写在的那份文件找([SymbolReference] 的 `file`):没写、写的就是这份文档的,按最近发出去的块树和这份文档的导入、用到的名字找;
* 别的文件的(定义卡片里的注释)按那份文件读成的文档找(见 [PreviewEditorProvider.resolveElsewhere])。
* 都是从它写在的那块的声明起一层层往外找那份文件定义的,再找导入的、代码里用到的语言自带的名字,
* 语言自带又问不到定义的是 `'builtin'`(见 [解析引用](vscode/src/codeReader/symbolLinks.ts#resolveReference))。出错时记一笔,当找不到。
*/
private async resolveSymbolLink(reference: SymbolReference): Promise<ReferenceTarget> {
const { document, sent, imports, uses } = this
try {
if (reference.file === undefined || reference.file === document.uri.toString())
return await this.references.resolve({ version: document.version, sent, imports, uses }, reference)
return await this.owner.resolveElsewhere(await workspace.openTextDocument(Uri.parse(reference.file)), reference)
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `没能找注释里的引用 [${reference.symbol}]:${err instanceof Error ? err.message : String(err)}` })
return undefined
}
}
/** 跳到链接指的符号:名字那段是要选中的范围,起点下标是预览要闪的定义处(见 [showSpot])。 */
private async showLinkTarget({ uri, selection }: LinkTarget): Promise<void> {
const doc = uri === this.document.uri.toString() ? this.document : await workspace.openTextDocument(Uri.parse(uri))
await this.showSpot(doc.uri, { range: new Range(doc.positionAt(selection.startIndex), doc.positionAt(selection.endIndex)), offset: selection.startIndex })
}
/** 跳到链接指的一处(符号或几行):在这份文件里就在本预览里滚过去、闪一下;别的文件同 ⌘ + 点击跳到定义(见 [openElsewhere])。 */
private async showSpot(uri: Uri, spot: TargetSpot): Promise<void> {
if (uri.toString() === this.document.uri.toString()) {
this.reveal(revealOf(spot))
return
}
await this.openElsewhere(uri, spot)
}
private markRepoLinkBroken(path: string | undefined, reference?: SymbolReference): void {
this.post({ type: 'repoLinkBroken', ...(path !== undefined && { path }), ...reference })
}
private markRepoLinkOpened(path: string | undefined, reference?: SymbolReference): void {
this.post({ type: 'repoLinkOpened', ...(path !== undefined && { path }), ...reference })
}
/**
* 重写 anchor 那块的注释:按最近一次发出去的块树找到它,记下它现在的样子(写回前比对),把发给模型的话和写回的办法交给 CodeQa。
* 点按钮(没有 request)发技能命令、开新会话;输入框里说的话接着这块最近一次重写的会话说,带上块现在在哪。
* 找不到这块、文件有没保存的修改(模型读的是磁盘上的文件)、开不了时,直接回一条失败。
*/
private async rewrite(askId: string, anchor: string, request: string | undefined, images: ClaudeImage[]): Promise<void> {
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(NOT_REWRITABLE)
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.postQa()
}
/** 把交回的正文写回这份文件;预览关了也照样按 uri 打开文档来写。 */
private async writeBack(job: CommentJob, body: string | undefined): Promise<string | undefined> {
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),
})
}
}
/** 一条仓库链接指到磁盘上的哪个文件或目录,路径按这一页的算法算(见 [linkBaseOf]);算不出来、不在、跑出根的没有。 */
async function linkEntry(base: LinkBase, rel: string): Promise<RepoEntry | undefined> {
const place = base.place(rel)
return place && resolveRepoEntry(place.root, place.rel)
}
/**
* `document` 这份文件的注释里不带路径的引用指到哪,解析过的记着(见 [ReferenceTargets])。文档符号问这种语言的扩展(同仓库链接找符号),
* 导入的、代码里用到的名字问语言扩展它在这份文件里的定义(同跳到定义,几处取第一处),
* 哪些名字是语言自带的问这份文件的语言插件([LanguageAdapter.builtins](vscode/src/codeReader/contract.ts#LanguageAdapter.builtins))。
*/
function referenceTargets(document: TextDocument, registry: Pick<CodeReaderRegistry, 'languageFor'>): ReferenceTargets {
return new ReferenceTargets({
uri: document.uri.toString(),
symbols: documentSymbolNodes,
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) } }
},
builtin: name => registry.languageFor(document.languageId)?.builtins?.has(name) ?? false,
versionOf: uri => workspace.textDocuments.find(doc => doc.uri.toString() === uri)?.version,
})
}
/** 消息里带的那条引用:只取 `symbol`、`block`、`file` 里写了的,报回页面时和页面问的那条一样。 */
function referenceOf({ symbol, block, file }: SymbolReference): SymbolReference {
return { symbol, ...(block !== undefined && { block }), ...(file !== undefined && { file }) }
}
/** 写回重写好的注释:换掉原来那几行(同块编辑),或在块上面插进新写的几行。 */
async function writeCommentEdit(document: TextDocument, edit: CommentEdit): Promise<WriteResult> {
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<WriteResult> {
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'
}