✨ 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

+7
View File
@@ -75,8 +75,14 @@ export interface QaThreadView {
*/
export interface QaMessage {
type: 'qa'
/** 不在工作区文件夹里时没地方存,问 AI 不可用 */
available: boolean
/** 块下面的问答,按块的记号 */
threads: QaThreadView[]
/** 这一页顶部、按路径记的问答;不挂在块上 */
path: QaEntryView[]
/** 顶部问的是这个文件还是这个文件夹 */
subject: 'file' | 'folder'
running: RunningAsk[]
}
@@ -119,6 +125,7 @@ export type ExtensionToWebview
| { type: 'repoLinkBroken', path: string, symbol?: string }
| { type: 'repoLinkOpened', path: string, symbol?: string }
| { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] }
| { type: 'focusPathAsk' }
/**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进)
@@ -0,0 +1,38 @@
/**
* 文件夹、以及代码阅读不认识的文件,用这个协议打开代码阅读预览:
* 文档是空的,页面上没有代码块,只在顶部聊天。源码文件直接开它自己的预览。
*/
import type { Uri, WorkspaceFolder } from 'vscode'
import * as path from 'node:path'
import { workspace } from 'vscode'
import { relativeQaKey } from '../qa/pathQa'
export const PATH_PREVIEW_SCHEME = 'code-reader-path'
export type PathSubject = 'file' | 'folder'
export function pathPreviewUri(target: Uri, kind: PathSubject): Uri {
return target.with({ scheme: PATH_PREVIEW_SCHEME, query: kind, fragment: '' })
}
/** 预览文档是路径页时,还原成真实文件或文件夹;普通源码预览不是。 */
export function pathPreviewOf(uri: Uri): { uri: Uri, kind: PathSubject } | undefined {
if (uri.scheme !== PATH_PREVIEW_SCHEME)
return undefined
if (uri.query !== 'file' && uri.query !== 'folder')
return undefined
return { uri: uri.with({ scheme: 'file', query: '', fragment: '' }), kind: uri.query }
}
/**
* 这条路径在哪个工作区文件夹、相对路径是什么。
* 工作区根本身的相对路径是空字符串(locate 不认根,顶部问答要认)。
*/
export function pathQaLocation(uri: Uri): { folder: WorkspaceFolder, key: string } | undefined {
const folder = workspace.getWorkspaceFolder(uri)
if (!folder)
return undefined
const key = relativeQaKey(path.posix.normalize(folder.uri.path), path.posix.normalize(uri.path))
return key === undefined ? undefined : { folder, key }
}
+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)
+23 -404
View File
@@ -1,415 +1,34 @@
/**
* 侧栏文件行、文件夹行的「问 AI」:单独一个面板,不属于任何一个文件的预览。
* 先问问题,再在同一面板里把回答流出来;不写进某一块代码的问答记录。
* 侧栏文件行、文件夹行的「问 AI」:打开代码阅读预览,在那一页顶部问。
* 源码文件开它自己的预览(下面仍是代码块)。文件夹、以及代码阅读不认识的文件,开一页没有代码块的预览。
*/
import type { WebviewPanel } from 'vscode'
import type { AskProfiles } from '../qa/askProfiles'
import type { PathAskSubject, PathChild } from '../qa/pathAskPrompt'
import type { ExcludeSetting } from './fileEntries'
import type { Disposable, Uri } from 'vscode'
import type { FileNode } from './fileTree'
import * as path from 'node:path'
import { commands, Disposable, FileType, ViewColumn, window, workspace } from 'vscode'
import { ClaudeCancelledError } from '../../cc/spawnClaude'
import { makeNonce } from '../../webview/html'
import { askClaude, askFailure } from '../qa/claudeAsk'
import { readFileHead } from '../qa/pathAskContext'
import { pathAskPrompt } from '../qa/pathAskPrompt'
import { createExcludeMatcher, sortEntries } from './fileEntries'
import { commands } from 'vscode'
import { pathPreviewUri } from '../editor/pathPreview'
export const ASK_PATH_COMMAND = 'superpowers.codeReader.askPath'
const VIEW_TYPE = 'superpowers.codeReader.pathAsk'
type PathTarget = Pick<FileNode, 'uri' | 'folder' | 'isDirectory'> & { relativePath: string }
interface ResetMessage { type: 'reset', path: string, kind: 'file' | 'folder' }
interface AskedMessage { type: 'asked', question: string }
interface StreamMessage { type: 'stream', text?: string, activity?: string }
interface EndMessage { type: 'end', status: 'done' | 'failed' | 'stopped', error?: string }
type ToPanel = ResetMessage | AskedMessage | StreamMessage | EndMessage
/** 打开或切到问路径的面板。点侧栏另一行时换到那一行,正在答的停掉。 */
export function registerPathAsk(profiles: AskProfiles): Disposable {
const panel = new PathAskPanel(profiles)
return Disposable.from(
commands.registerCommand(ASK_PATH_COMMAND, (node?: FileNode) => {
void panel.open(node)
}),
panel,
)
export function registerPathAsk(
isSource: (uri: Uri) => Promise<boolean>,
openAsking: (uri: Uri) => Promise<void>,
): Disposable {
return commands.registerCommand(ASK_PATH_COMMAND, (node?: FileNode) => {
void openAsked(node, isSource, openAsking)
})
}
class PathAskPanel implements Disposable {
private panel: WebviewPanel | undefined
private whenReady = Promise.resolve()
private target: PathTarget | undefined
/** 换了路径就加一,旧的回答不再往面板上画 */
private generation = 0
private controller: AbortController | undefined
constructor(private readonly profiles: AskProfiles) {}
async open(node?: FileNode): Promise<void> {
if (!node || node.isRoot)
return
const gen = ++this.generation
this.controller?.abort()
this.controller = undefined
const relativePath = path.posix.relative(node.folder.uri.path, node.uri.path)
this.target = { uri: node.uri, folder: node.folder, isDirectory: node.isDirectory, relativePath }
const title = `问 AI · ${relativePath}`
if (!this.panel) {
this.create(title)
}
else {
this.panel.title = title
this.panel.reveal(ViewColumn.Active, false)
}
await this.whenReady
if (gen !== this.generation || !this.panel)
return
this.post({ type: 'reset', path: relativePath, kind: node.isDirectory ? 'folder' : 'file' })
}
dispose(): void {
this.generation++
this.controller?.abort()
this.controller = undefined
this.panel?.dispose()
this.panel = undefined
}
private create(title: string): void {
const panel = window.createWebviewPanel(VIEW_TYPE, title, { viewColumn: ViewColumn.Active, preserveFocus: false }, {
enableScripts: true,
retainContextWhenHidden: true,
})
this.panel = panel
let markReady: () => void = () => {}
this.whenReady = new Promise((resolve) => {
markReady = resolve
})
const nonce = makeNonce()
panel.webview.html = pathAskHtml(nonce)
panel.webview.onDidReceiveMessage((msg: unknown) => {
if (!isRecord(msg) || typeof msg.type !== 'string')
return
if (msg.type === 'ready')
markReady()
else if (msg.type === 'ask' && typeof msg.question === 'string')
void this.submit(msg.question)
else if (msg.type === 'stop')
this.controller?.abort()
})
panel.onDidDispose(() => {
if (this.panel !== panel)
return
this.panel = undefined
this.generation++
this.controller?.abort()
this.controller = undefined
})
}
/** 空问题不发。回答过程里的字和「正在读哪个文件」直接画在这个面板上。 */
private async submit(question: string): Promise<void> {
const text = question.trim()
const target = this.target
if (!text || !target || this.controller)
return
const gen = this.generation
const controller = new AbortController()
this.controller = controller
this.post({ type: 'asked', question: text })
try {
const described = await describePath(target)
if (gen !== this.generation)
return
const profile = await this.profiles.current()
if (gen !== this.generation)
return
const prompt = pathAskPrompt(described, text)
await askClaude({
prompt,
images: [],
...(profile && { profilePath: profile.path }),
cwd: target.folder.uri.fsPath,
signal: controller.signal,
onEvent: (event) => {
if (gen !== this.generation)
return
if (event.kind === 'text')
this.post({ type: 'stream', text: event.text })
else if (event.kind === 'activity')
this.post({ type: 'stream', activity: event.label })
},
})
if (gen !== this.generation)
return
this.post({ type: 'end', status: 'done' })
}
catch (err) {
if (gen !== this.generation)
return
if (err instanceof ClaudeCancelledError)
this.post({ type: 'end', status: 'stopped' })
else
this.post({ type: 'end', status: 'failed', error: askFailure(err) })
}
finally {
if (this.controller === controller)
this.controller = undefined
}
}
private post(message: ToPanel): void {
void this.panel?.webview.postMessage(message).then(undefined, () => {})
async function openAsked(
node: FileNode | undefined,
isSource: (uri: Uri) => Promise<boolean>,
openAsking: (uri: Uri) => Promise<void>,
): Promise<void> {
if (!node || node.isRoot)
return
if (node.isDirectory) {
await openAsking(pathPreviewUri(node.uri, 'folder'))
return
}
}
/** 文件带开头,文件夹带这一层(和侧栏同一套排除、同一排序)。 */
async function describePath(target: PathTarget): Promise<PathAskSubject> {
if (target.isDirectory)
return { kind: 'folder', relativePath: target.relativePath, children: await folderChildren(target) }
return { kind: 'file', relativePath: target.relativePath, head: await readFileHead(target.uri.fsPath) }
}
async function folderChildren(target: PathTarget): Promise<PathChild[] | { unavailable: string }> {
let entries: [string, FileType][]
try {
entries = await workspace.fs.readDirectory(target.uri)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { unavailable: `列不出来(${message})。` }
}
const excluded = createExcludeMatcher(workspace.getConfiguration('files', target.folder).get<ExcludeSetting>('exclude'))
const names = new Set(entries.map(([name]) => name))
const dir = target.relativePath
return sortEntries(entries.map(([name, type]) => ({ name, isDirectory: (type & FileType.Directory) !== 0 })))
.filter(entry => !excluded(dir ? `${dir}/${entry.name}` : entry.name, names))
.map(entry => ({ name: entry.name, kind: entry.isDirectory ? 'folder' : 'file' }))
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/** 面板里的一页:暗色遮罩铺满,中间一张卡片。问、流式回答、停止都在这张卡片上。 */
function pathAskHtml(nonce: string): string {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>问 AI</title>
<style>
html, body { height: 100%; margin: 0; }
body {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
color: var(--vscode-foreground);
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
.card {
display: flex;
flex-direction: column;
width: min(40rem, calc(100% - 2rem));
max-height: calc(100% - 2rem);
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
border: 1px solid var(--vscode-panel-border, var(--vscode-widget-border, transparent));
border-radius: 8px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
header { padding: 14px 16px 0; }
.kind { opacity: 0.7; font-size: 12px; }
h1 {
margin: 2px 0 0;
font-size: 13px;
font-weight: 600;
word-break: break-all;
}
.thread { overflow: auto; padding: 12px 16px; min-height: 0; }
.question { margin: 0 0 8px; font-weight: 600; white-space: pre-wrap; word-break: break-word; }
.answer {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--vscode-editor-font-family, var(--vscode-font-family));
}
.status { margin: 8px 0 0; opacity: 0.75; }
.error { margin: 8px 0 0; color: var(--vscode-errorForeground); white-space: pre-wrap; }
form { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px 16px; }
textarea {
resize: vertical;
min-height: 4.5rem;
padding: 8px;
border-radius: 4px;
border: 1px solid var(--vscode-input-border, transparent);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font: inherit;
}
.actions { display: flex; justify-content: flex-end; gap: 8px; }
button {
border: none;
border-radius: 4px;
padding: 4px 12px;
cursor: pointer;
font: inherit;
}
button.primary { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
button.primary:hover { background: var(--vscode-button-hoverBackground); }
button.secondary { background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); }
button:disabled { opacity: 0.45; cursor: default; }
</style>
</head>
<body>
<div class="card" role="dialog" aria-labelledby="path">
<header>
<div class="kind" id="kind"></div>
<h1 id="path"></h1>
</header>
<div class="thread" id="thread" hidden>
<p class="question" id="q"></p>
<pre class="answer" id="answer" hidden></pre>
<p class="status" id="status" hidden></p>
<p class="error" id="error" hidden></p>
</div>
<form id="form">
<textarea id="question" rows="3" placeholder="想问什么,比如这个名字是什么意思"></textarea>
<div class="actions">
<button type="button" class="secondary" id="stop" hidden>停止</button>
<button type="submit" class="primary" id="send" disabled>发送</button>
</div>
</form>
</div>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi()
const pathEl = document.getElementById('path')
const kindEl = document.getElementById('kind')
const thread = document.getElementById('thread')
const questionEl = document.getElementById('q')
const answerEl = document.getElementById('answer')
const statusEl = document.getElementById('status')
const errorEl = document.getElementById('error')
const form = document.getElementById('form')
const input = document.getElementById('question')
const sendBtn = document.getElementById('send')
const stopBtn = document.getElementById('stop')
let running = false
let text = ''
let activity = ''
let frame
const paint = () => {
frame = undefined
answerEl.textContent = text
answerEl.hidden = text === ''
if (!running)
return
statusEl.hidden = false
statusEl.textContent = activity || (text === '' ? '正在想…' : '正在回答…')
}
const setRunning = (on) => {
running = on
input.disabled = on
stopBtn.hidden = !on
if (!on)
statusEl.hidden = true
sendBtn.disabled = on || input.value.trim() === ''
}
window.addEventListener('message', (event) => {
const msg = event.data
if (!msg || typeof msg.type !== 'string')
return
if (msg.type === 'reset') {
clearTimeout(frame)
text = ''
activity = ''
pathEl.textContent = typeof msg.path === 'string' ? msg.path : ''
kindEl.textContent = msg.kind === 'folder' ? '文件夹' : '文件'
questionEl.textContent = ''
answerEl.textContent = ''
answerEl.hidden = true
errorEl.hidden = true
errorEl.textContent = ''
thread.hidden = true
input.value = ''
setRunning(false)
input.focus()
return
}
if (msg.type === 'asked') {
thread.hidden = false
questionEl.textContent = typeof msg.question === 'string' ? msg.question : ''
text = ''
activity = ''
answerEl.textContent = ''
answerEl.hidden = true
errorEl.hidden = true
errorEl.textContent = ''
setRunning(true)
paint()
return
}
if (msg.type === 'stream') {
if (typeof msg.text === 'string') {
text = msg.text
activity = ''
}
if (typeof msg.activity === 'string')
activity = msg.activity
frame ??= setTimeout(paint, 80)
return
}
if (msg.type === 'end') {
clearTimeout(frame)
paint()
setRunning(false)
if (msg.status === 'failed') {
errorEl.hidden = false
errorEl.textContent = typeof msg.error === 'string' && msg.error ? msg.error : '没答成。'
}
else if (msg.status === 'stopped') {
errorEl.hidden = false
errorEl.textContent = '已停止。'
}
}
})
const send = () => {
const question = input.value.trim()
if (!question || running)
return
vscode.postMessage({ type: 'ask', question })
}
form.addEventListener('submit', (event) => {
event.preventDefault()
send()
})
input.addEventListener('input', () => {
sendBtn.disabled = running || input.value.trim() === ''
})
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault()
send()
}
})
stopBtn.addEventListener('click', () => vscode.postMessage({ type: 'stop' }))
vscode.postMessage({ type: 'ready' })
</script>
</body>
</html>`
await openAsking(await isSource(node.uri) ? node.uri : pathPreviewUri(node.uri, 'file'))
}
+12 -2
View File
@@ -9,6 +9,7 @@ import { shellCommandRules } from './commands/shellCommands'
import { systemCommandRules } from './commands/systemCommands'
import { BlockFilter } from './editor/blockFilter'
import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation'
import { PATH_PREVIEW_SCHEME } from './editor/pathPreview'
import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor'
import { showPreview, showSource } from './editor/switchEditor'
import { registerCopyCommands } from './explorer/copyCommands'
@@ -82,7 +83,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
return runtime.parse(adapter, languageId, source)
}, report))
const resolve = (reference: RuleReference): Promise<RuleLocation[]> => resolveReference(reference, registry.libraryRules(), indexes, report)
const qa = new CodeQa(context.globalState)
const qa = new CodeQa(context.globalState, context.workspaceState)
const namesIn = async (languageId: string, name: string): Promise<IndexedName[]> => {
const adapter = registry.languageFor(languageId)
return adapter ? indexedNames((index, key) => indexes.find(index, key), adapter, name) : []
@@ -102,7 +103,16 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
indexes,
registerNoteCommands(notes),
registerCopyCommands(),
registerPathAsk(qa.profiles),
registerPathAsk(
async (uri) => {
const languageId = await languageOf(uri)
return languageId !== undefined && registry.languageFor(languageId) !== undefined
},
uri => previews.openAsking(uri),
),
workspace.registerTextDocumentContentProvider(PATH_PREVIEW_SCHEME, {
provideTextDocumentContent: () => '',
}),
registry.onDidChange(() => {
void commands.executeCommand('setContext', LANGUAGE_IDS_CONTEXT, registry.languageIds())
previews.refresh()
+1 -1
View File
@@ -84,7 +84,7 @@ describe('正在答的问题', () => {
const { runner } = fakeRunner()
const asks = new QaAsks(runner)
asks.start(start('1', 'function f'), listener())
expect(asks.start(start('2', 'function f'), listener())).toBe('这块已经在问了,等它答完或者先停下再问。')
expect(asks.start(start('2', 'function f'), listener())).toBe('这里已经在问了,等它答完或者先停下再问。')
expect(asks.start(start('3', 'function f', 'file:///b.py'), listener())).toBeUndefined()
expect(asks.start(start('1', 'function g'), listener())).toBe('这个问题已经在问了。')
})
+1 -1
View File
@@ -57,7 +57,7 @@ export class QaAsks {
if (this.running.has(askId))
return '这个问题已经在问了。'
if ([...this.running.values()].some(item => item.owner === owner && item.ask.anchor === anchor))
return '这块已经在问了,等它答完或者先停下再问。'
return '这里已经在问了,等它答完或者先停下再问。'
if (this.running.size >= this.limit)
return `同时最多问 ${this.limit} 个问题,等前面的答完再问。`
const controller = new AbortController()
+151 -26
View File
@@ -1,16 +1,21 @@
import type { Disposable, Memento } from 'vscode'
import type { Event, Memento } from 'vscode'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { QaMessage } from '../editor/messages'
import type { PathSubject } from '../editor/pathPreview'
import type { AskEnd, RunningAsk } from './asks'
import type { AskRunner } from './claudeAsk'
import type { QaEntry } from './qaMap'
import { randomUUID } from 'node:crypto'
import { EventEmitter, Uri } from 'vscode'
import { Disposable, EventEmitter, Uri, window } from 'vscode'
import { pathQaLocation } from '../editor/pathPreview'
import { FolderStore, locate } from '../folderStore'
import { AskProfiles } from './askProfiles'
import { QaAsks } from './asks'
import { askClaude } from './claudeAsk'
import { isQaImageType, QA_IMAGE_DIR, writeQaImages } from './qaImages'
import { describePath } from './pathAskDescribe'
import { pathAskFollowUp, pathAskPrompt } from './pathAskPrompt'
import { parsePathQa, PATH_ASK_ANCHOR, PATH_QA_STATE_KEY, pathQaEntries, pathQaKey, withoutPathQa, withPathQa } from './pathQa'
import { isQaImageType, QA_IMAGE_DIR, removeQaImages, writeQaImages } from './qaImages'
import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap'
/** 正在答的问题有了进展(update),或答完了(end;答成了带存下的那条的 id)。owner 是问的那份文件的 uri。 */
@@ -30,52 +35,72 @@ export interface AskRequest {
}
/**
* 代码问答:问 Claude Code、答完存进各工作区文件夹的 `.spx/code-qa.json`(改名、删文件、外部改动的跟随同文件注释,见 FolderStore)。
* 问答挂在块的位置记号上(见 BlockAnchors),按文件取给预览。
* 代码问答:块下面的问 Claude Code、答完存进各工作区文件夹的 `.spx/code-qa.json`
* (改名、删文件、外部改动的跟随同文件注释,见 FolderStore),挂在块的位置记号上。
* 预览顶部问的是整个文件或文件夹,按路径记在这个窗口的 workspaceState 里,不进那份文件。
*/
export class CodeQa implements Disposable {
private readonly store = new FolderStore(QA_FILE)
private readonly asks: QaAsks
private readonly answered = new EventEmitter<AskEvent>()
/** 顶部问答变了的那些文件或文件夹 */
private readonly pathChanged = new EventEmitter<Uri[]>()
/** 顶部问答的读写排成一队,两次同时答完不会互相盖掉 */
private pathQueue: Promise<void> = Promise.resolve()
/** 问的时候用哪个 profile(所有预览共用,记在 globalState 里) */
readonly profiles: AskProfiles
/** 问答变了的那些文件 */
readonly onDidChange = this.store.onDidChange
/** 块下面或顶部的问答变了的那些文件 / 文件夹 */
readonly onDidChange: Event<Uri[]> = (listener, thisArgs, disposables) => {
const subscription = Disposable.from(
this.store.onDidChange(listener, thisArgs),
this.pathChanged.event(listener, thisArgs),
)
disposables?.push(subscription)
return subscription
}
readonly onDidAnswer = this.answered.event
constructor(state: Pick<Memento, 'get' | 'update'>, runner: AskRunner = askClaude) {
this.profiles = new AskProfiles(state)
constructor(
profilesState: Pick<Memento, 'get' | 'update'>,
private readonly paths: Pick<Memento, 'get' | 'update'>,
runner: AskRunner = askClaude,
) {
this.profiles = new AskProfiles(profilesState)
this.asks = new QaAsks(runner)
}
/** uri 这份文件的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);文件不在工作区文件夹里时没有。 */
/** uri 这份文件或文件夹的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);不在工作区文件夹里时没有。 */
imageRoot(uri: Uri): Uri | undefined {
const at = locate(uri)
const at = pathQaLocation(uri)
return at && Uri.joinPath(at.folder.uri, ...QA_IMAGE_DIR.split('/'))
}
/**
* 发给预览的这份文件的问答;文件不在工作区文件夹里时没地方存,问 AI 不可用。
* 发给预览的问答。块下面的按块的记号;顶部的按这条路径,subject 说是文件还是文件夹。
* 不在工作区文件夹里时没地方存,问 AI 不可用。
* 问题带的图片换成预览里能用的地址(imageUrl,即 webview.asWebviewUri)。
*/
view(uri: Uri, imageUrl: (file: Uri) => string): QaMessage {
view(uri: Uri, imageUrl: (file: Uri) => string, subject: PathSubject): QaMessage {
const at = locate(uri)
const pathAt = pathQaLocation(uri)
const threads = at ? this.store.get(at.folder, at.key) ?? [] : []
const pathEntries = pathAt ? pathQaEntries(this.pathMap(), pathQaKey(pathAt.folder.uri.toString(), pathAt.key)) : []
const root = this.imageRoot(uri)
const show = ({ id, question, answer, createdAt, profile, images }: QaEntry) => ({
id,
question,
answer,
createdAt,
...(profile && { profile }),
...(root && images && { images: images.map(name => imageUrl(Uri.joinPath(root, name))) }),
})
return {
type: 'qa',
available: at !== undefined,
threads: threads.map(({ anchor, entries }) => ({
anchor,
entries: entries.map(({ id, question, answer, createdAt, profile, images }) => ({
id,
question,
answer,
createdAt,
...(profile && { profile }),
...(root && images && { images: images.map(name => imageUrl(Uri.joinPath(root, name))) }),
})),
})),
available: pathAt !== undefined,
subject,
threads: threads.map(({ anchor, entries }) => ({ anchor, entries: entries.map(show) })),
path: pathEntries.map(show),
running: this.asks.runningFor(uri.toString()),
}
}
@@ -135,12 +160,82 @@ export class CodeQa implements Disposable {
})
}
/**
* 问这一整个文件或文件夹。材料是路径(文件再加开头若干行,文件夹再加这一层的名字),
* 答成了记在 workspaceState 里,不进按块存的那份文件。没答成不记。
* 问过的接着最近一次的会话;进行中再问、问不了时返回给人看的原因。
*/
async askPath(uri: Uri, kind: PathSubject, request: { askId: string, question: string, images: ClaudeImage[] }): Promise<string | undefined> {
const at = pathQaLocation(uri)
if (!at)
return '这个路径不在工作区文件夹里,问答没地方存。'
const { askId, question, images } = request
if (!images.every(image => isQaImageType(image.mediaType)))
return '只能带 PNG、JPEG、GIF、WebP 图片。'
const described = await describePath({ uri, folder: at.folder, isDirectory: kind === 'folder', relativePath: at.key })
const profile = await this.profiles.current()
const owner = uri.toString()
const key = pathQaKey(at.folder.uri.toString(), at.key)
const sessionId = pathQaEntries(this.pathMap(), key).at(-1)?.sessionId
const prompt = pathAskPrompt(described, question)
const followUp = pathAskFollowUp(described.relativePath, kind, question)
return this.asks.start({
owner,
askId,
anchor: PATH_ASK_ANCHOR,
question,
run: {
prompt,
images,
...(sessionId && { followUp: { sessionId, prompt: followUp } }),
...(profile && { profilePath: profile.path }),
cwd: at.folder.uri.fsPath,
},
}, {
update: ask => this.answered.fire({ owner, kind: 'update', ask }),
end: async (ask, end) => {
if (end.status !== 'done') {
this.answered.fire({ owner, kind: 'end', ask, end })
return
}
const id = randomUUID()
let names: string[]
try {
names = await writeQaImages(at.folder.uri.fsPath, id, images)
}
catch (err) {
const error = `回答没存上:图片写不进 ${QA_IMAGE_DIR}(${err instanceof Error ? err.message : String(err)})`
this.answered.fire({ owner, kind: 'end', ask, end: { status: 'failed', error } })
return
}
const entry: QaEntry = {
id,
question,
answer: end.answer.text,
createdAt: new Date().toISOString(),
sessionId: end.answer.sessionId,
...(profile && { profile: profile.name }),
...(names.length > 0 && { images: names }),
}
await this.enqueuePath(async () => {
await this.paths.update(PATH_QA_STATE_KEY, withPathQa(this.pathMap(), key, entry))
})
this.answered.fire({ owner, kind: 'end', ask, end, entryId: entry.id })
this.pathChanged.fire([uri])
},
})
}
stop(askId: string): void {
this.asks.stop(askId)
}
/** 删掉一次问答,不用确认;块下面删光了这块就不挂了,带的图片跟着删(见 QA_FILE.attachments)。 */
/** 删掉一次问答,不用确认。块下面的从按块存的文件里删(图片跟着删);顶部的从路径记录里删。 */
async delete(uri: Uri, anchor: string, entryId: string): Promise<void> {
if (anchor === PATH_ASK_ANCHOR) {
await this.deletePath(uri, entryId)
return
}
const at = locate(uri)
if (at)
await this.store.update(at.folder, map => withoutEntry(map, at.key, anchor, entryId))
@@ -149,6 +244,36 @@ export class CodeQa implements Disposable {
dispose(): void {
this.asks.stopAll()
this.answered.dispose()
this.pathChanged.dispose()
this.store.dispose()
}
private pathMap() {
return parsePathQa(this.paths.get(PATH_QA_STATE_KEY))
}
private enqueuePath(task: () => Promise<void>): Promise<void> {
const run = this.pathQueue.then(task)
this.pathQueue = run.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
void window.showErrorMessage(`顶部问答没存上:${message}`)
})
return run
}
/** 删顶部的一次问答和它带的图片,然后告诉开着的预览。 */
private async deletePath(uri: Uri, entryId: string): Promise<void> {
const at = pathQaLocation(uri)
if (!at)
return
const key = pathQaKey(at.folder.uri.toString(), at.key)
let images: string[] = []
await this.enqueuePath(async () => {
const map = this.pathMap()
images = pathQaEntries(map, key).find(entry => entry.id === entryId)?.images ?? []
await this.paths.update(PATH_QA_STATE_KEY, withoutPathQa(map, key, entryId))
})
await removeQaImages(at.folder.uri.fsPath, images)
this.pathChanged.fire([uri])
}
}
@@ -0,0 +1,35 @@
/**
* 顶部问答带给模型的材料:文件是相对路径加开头若干行,文件夹是相对路径加这一层的名字。
*/
import type { Uri, WorkspaceFolder } from 'vscode'
import type { ExcludeSetting } from '../explorer/fileEntries'
import type { PathAskSubject, PathChild } from './pathAskPrompt'
import { FileType, workspace } from 'vscode'
import { createExcludeMatcher, sortEntries } from '../explorer/fileEntries'
import { readFileHead } from './pathAskContext'
import { displayRelativePath } from './pathQa'
export async function describePath(target: { uri: Uri, folder: WorkspaceFolder, isDirectory: boolean, relativePath: string }): Promise<PathAskSubject> {
const relativePath = displayRelativePath(target.relativePath)
if (target.isDirectory)
return { kind: 'folder', relativePath, children: await folderChildren(target.uri, target.folder, target.relativePath) }
return { kind: 'file', relativePath, head: await readFileHead(target.uri.fsPath) }
}
/** 文件夹这一层的子文件和子目录;files.exclude 排除的不列。列不出来时只留原因。 */
async function folderChildren(uri: Uri, folder: WorkspaceFolder, relativePath: string): Promise<PathChild[] | { unavailable: string }> {
let entries: [string, FileType][]
try {
entries = await workspace.fs.readDirectory(uri)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { unavailable: `列不出来(${message})。` }
}
const excluded = createExcludeMatcher(workspace.getConfiguration('files', folder).get<ExcludeSetting>('exclude'))
const names = new Set(entries.map(([name]) => name))
return sortEntries(entries.map(([name, type]) => ({ name, isDirectory: (type & FileType.Directory) !== 0 })))
.filter(entry => !excluded(relativePath ? `${relativePath}/${entry.name}` : entry.name, names))
.map(entry => ({ name: entry.name, kind: entry.isDirectory ? 'folder' : 'file' }))
}
@@ -44,6 +44,12 @@ export function pathAskPrompt(subject: PathAskSubject, question: string): string
return [LEAD, where, detail, `问题:${question}`].join('\n\n')
}
/** 这条路径已经问过、接着上一次会话问时发的话:只说还是这个文件或文件夹,材料在会话里。 */
export function pathAskFollowUp(relativePath: string, kind: 'file' | 'folder', question: string): string {
const noun = kind === 'folder' ? '文件夹' : '文件'
return `接着问(还是 ${relativePath} 这个${noun},需要时重新读):${question}`
}
function fileHead(head: PathHead): string {
if ('unavailable' in head)
return `文件开头没附上:${head.unavailable}`
+34
View File
@@ -0,0 +1,34 @@
import type { QaEntry } from './qaMap'
import { describe, expect, it } from 'vitest'
import { parsePathQa, pathQaEntries, pathQaKey, relativeQaKey, withoutPathQa, withPathQa } from './pathQa'
function entry(id: string): QaEntry {
return { id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-10-02T00:00:00.000Z', sessionId: `s-${id}` }
}
const FOLDER = 'file:///work/app'
const FILE = pathQaKey(FOLDER, 'src/main.ts')
const DIR = pathQaKey(FOLDER, 'src')
describe('顶部问答按路径记', () => {
it('同一路径再读回上次的问答,另一条路径读不到', () => {
const stored = withPathQa(withPathQa({}, FILE, entry('1')), FILE, entry('2'))
const again = parsePathQa(JSON.parse(JSON.stringify(stored)))
expect(pathQaEntries(again, FILE).map(item => item.question)).toEqual(['问 1', '问 2'])
expect(pathQaEntries(again, DIR)).toEqual([])
expect(pathQaEntries(again, pathQaKey('file:///work/other', 'src/main.ts'))).toEqual([])
})
it('文件夹和文件各记各的;删一条不影响另一条', () => {
const stored = withoutPathQa(withPathQa(withPathQa({}, FILE, entry('f')), DIR, entry('d')), FILE, 'f')
expect(pathQaEntries(stored, FILE)).toEqual([])
expect(pathQaEntries(stored, DIR).map(item => item.id)).toEqual(['d'])
})
it('工作区根是空相对路径,走不出去的路径没有键', () => {
expect(relativeQaKey('/work/app', '/work/app')).toBe('')
expect(relativeQaKey('/work/app', '/work/app/src/main.ts')).toBe('src/main.ts')
expect(relativeQaKey('/work/app', '/work/other')).toBeUndefined()
expect(pathQaKey(FOLDER, '')).not.toBe(pathQaKey(FOLDER, 'src'))
})
})
+97
View File
@@ -0,0 +1,97 @@
/**
* 预览顶部的问答:按「工作区文件夹 + 相对路径」记在这个窗口的 workspaceState 里。
* 文件和文件夹各用各的路径;不写进按块存的 `.spx/code-qa.json`。
*/
import type { QaEntry } from './qaMap'
import * as path from 'node:path'
import { normalizeKey } from '../pathMap'
/** workspaceState 里这份表的键 */
export const PATH_QA_STATE_KEY = 'codeReader.pathQa'
/**
* 顶部这一轮的记号。块的记号是「种类 名字」,不会是这个,
* 所以页面上按记号取问答时,顶部的不会挂到某一块下面。
*/
export const PATH_ASK_ANCHOR = '\0path'
/** 工作区文件夹 Uri → 那条路径上的问答(按问的先后) */
export type PathQaMap = Record<string, QaEntry[]>
/** 工作区根的相对路径是空字符串,跟任何一个文件或子目录都不同。 */
export function relativeQaKey(rootPath: string, targetPath: string): string | undefined {
const relative = path.posix.relative(rootPath, targetPath)
if (relative === '')
return ''
return normalizeKey(relative)
}
/** 同一工作区文件夹、同一相对路径才是同一条记录。 */
export function pathQaKey(folderUri: string, relativePath: string): string {
return `${folderUri}\n${relativePath}`
}
/** 给人看的相对路径;工作区根写成 `.`。 */
export function displayRelativePath(relativePath: string): string {
return relativePath === '' ? '.' : relativePath
}
export function pathQaEntries(map: PathQaMap, key: string): QaEntry[] {
return map[key] ?? []
}
/** 新的一次问答接在这条路径已有的后面,别的路径不动。 */
export function withPathQa(map: PathQaMap, key: string, entry: QaEntry): PathQaMap {
return { ...map, [key]: [...pathQaEntries(map, key), entry] }
}
/** 删掉这一次;这条路径问光了就把键拿掉。没有这一次时原样返回。 */
export function withoutPathQa(map: PathQaMap, key: string, entryId: string): PathQaMap {
const entries = pathQaEntries(map, key)
const kept = entries.filter(entry => entry.id !== entryId)
if (kept.length === entries.length)
return map
if (kept.length === 0) {
const next = { ...map }
delete next[key]
return next
}
return { ...map, [key]: kept }
}
/** 读 workspaceState 里存的表;认不出的丢掉,不让一条坏数据把整份弄丢。 */
export function parsePathQa(value: unknown): PathQaMap {
if (!isRecord(value))
return {}
const map: PathQaMap = {}
for (const [key, entries] of Object.entries(value)) {
if (!Array.isArray(entries))
continue
const parsed = entries.flatMap((entry) => {
const one = parseEntry(entry)
return one ? [one] : []
})
if (parsed.length > 0)
map[key] = parsed
}
return map
}
function parseEntry(value: unknown): QaEntry | undefined {
if (!isRecord(value))
return undefined
const { id, question, answer, createdAt, sessionId, profile, images } = value
if (typeof id !== 'string' || typeof question !== 'string' || typeof answer !== 'string' || typeof createdAt !== 'string' || typeof sessionId !== 'string')
return undefined
const entry: QaEntry = { id, question, answer, createdAt, sessionId }
if (typeof profile === 'string' && profile !== '')
entry.profile = profile
if (Array.isArray(images) && images.every(name => typeof name === 'string'))
entry.images = images
return entry
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+7
View File
@@ -38,3 +38,10 @@ export async function writeQaImages(rootPath: string, entryId: string, images: r
await Promise.all(images.map((image, index) => fsp.writeFile(path.join(dir, names[index]), Buffer.from(image.base64, 'base64'))))
return names
}
/** 删掉这条问答带的图片。名字不是这边起的不碰;文件已经没了也当删完。 */
export async function removeQaImages(rootPath: string, names: readonly string[]): Promise<void> {
await Promise.all(names.filter(isQaImageName).map(name =>
fsp.unlink(path.join(rootPath, ...QA_IMAGE_DIR.split('/'), name)).catch(() => undefined),
))
}