✨ feat(vscode): 文件和文件夹在预览顶部问

侧栏「问 AI」打开代码阅读预览。文件夹是没有代码块的一页,源码文件在整页顶部聊,代码块下面的问答不动。历史按工作区文件夹和相对路径记在窗口状态里,不写进按块存的记录。
This commit is contained in:
cruldra committed 2026-10-03 03:22:20 +08:00
1 parent 2be635d1e5
commit 2c10acf9c4
19 files changed
+610 -459

No files matched your search

+84 -12
View File
@@ -25,6 +25,7 @@ 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 { definitionAction } from './definitionAction'
@@ -33,6 +34,7 @@ import { DefinitionSources } from './definitionSources'
import { documentMessage } from './documentMessage'
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
import { occurrencesOf } from './occurrences'
import { pathPreviewOf } from './pathPreview'
import { findRepoSymbol, isImageFile, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath'
export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
@@ -52,6 +54,8 @@ export interface DiffBases {
*/
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 子句用的上下文键 */
@@ -74,13 +78,32 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
readonly diffBases: DiffBases,
) {}
/** 侧栏「问 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(document.uri)
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 preview = new Preview(document, panel, this, this.registry, this.runtime, this.filter, this.reveals.get(key))
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(() => {
@@ -140,6 +163,8 @@ type RewriteOutcome
class Preview {
/** webview 发过 ready 之后才发消息;标签页切走时页面被销毁,切回来重新加载、再发 ready,这期间也不发 */
private ready = false
/** 侧栏「问 AI」打开的:页面准备好后把光标放进顶部输入框 */
private pendingPathFocus = false
/** 解析是异步的:只发最后一次请求的结果,慢的旧结果不能盖掉新的 */
private latest = 0
private timer: NodeJS.Timeout | undefined
@@ -179,7 +204,9 @@ class Preview {
* 之后页面因标签页切走重新加载,按 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) => {
@@ -227,7 +254,7 @@ class Preview {
this.post({ type: 'filter', ...state })
}),
owner.qa.onDidChange((uris) => {
if (uris.some(uri => uri.toString() === document.uri.toString()))
if (uris.some(uri => uri.toString() === this.subject().uri.toString()))
this.postQa()
}),
owner.qa.profiles.subscribe((choice) => {
@@ -235,7 +262,7 @@ class Preview {
this.post({ type: 'askProfiles', ...choice })
}),
owner.qa.onDidAnswer((event) => {
if (event.owner !== document.uri.toString() || !this.ready)
if (event.owner !== this.subject().uri.toString() || !this.ready)
return
if (event.kind === 'update') {
const { askId, text, activity } = event.ask
@@ -272,6 +299,23 @@ class Preview {
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()
@@ -405,8 +449,25 @@ class Preview {
}
private postQa(): void {
if (this.ready)
this.post(this.owner.qa.view(this.document.uri, file => this.panel.webview.asWebviewUri(file).toString()))
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 列表每次现读(同看板),读完页面还在才发。 */
@@ -421,13 +482,22 @@ class Preview {
* 找不到这块(代码刚改过、还没重新发)或问不了时,直接回一条失败。
*/
private async ask(askId: string, anchor: string, question: string, images: ClaudeImage[]): Promise<void> {
const at = locate(this.document.uri)
const subject = at && this.sent && { path: at.key, languageId: this.document.languageId, blocks: this.sent.blocks, source: this.sent.source, anchor }
const prompt = subject && askPrompt(subject, question)
const followUp = subject && followUpPrompt(subject, question)
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(this.document.uri, { askId, anchor, question, prompt, followUp, images })
: 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
@@ -449,6 +519,8 @@ class Preview {
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)
@@ -460,7 +532,7 @@ class Preview {
this.owner.qa.stop(msg.askId)
return
case 'deleteQa':
await this.owner.qa.delete(this.document.uri, msg.anchor, msg.entryId)
await this.owner.qa.delete(this.subject().uri, msg.anchor, msg.entryId)
return
case 'setFilter':
await this.filter.set(msg.excluded)