✨ 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

+4
View File
@@ -76,6 +76,10 @@
"selector": [
{
"filenamePattern": "*"
},
{
"filenamePattern": "*",
"scheme": "code-reader-path"
}
],
"priority": "option"
+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),
))
}
+27 -1
View File
@@ -6,6 +6,7 @@ import type { OccurrencePage } from './useOccurrences'
import type { ViewState } from './viewState'
import { TriangleAlert } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AskThread } from './AskThread'
import { BlockLevel } from './BlockTree'
import { DiffBar, NewFileBanner } from './DiffBar'
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
@@ -14,6 +15,7 @@ import { coveredByEditing, startEditing } from './editing'
import { applyFilter, excludedBy, keptWhileEditing } from './filter'
import { FilterBar } from './FilterBar'
import { OverviewRuler } from './OverviewRuler'
import { asksOf, historyOf, PATH_ASK_ANCHOR } from './qa'
import { useChangeNav } from './useChangeNav'
import { useDefinitionLinks } from './useDefinitionLinks'
import { HighlightContext, useOccurrences } from './useOccurrences'
@@ -39,6 +41,8 @@ function savedBlockModes(): BlockModes {
export function App(): ReactElement | null {
// 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答
const qa = useQa()
/** 侧栏「问 AI」要聚焦顶部输入框时加一 */
const [pathFocus, setPathFocus] = useState(0)
/** 按注释规范重写的过程,只在这一页上 */
const rewrites = useRewrite()
const [doc, setDoc] = useState<DocumentMessage | null>(null)
@@ -121,6 +125,9 @@ export function App(): ReactElement | null {
return
case 'diff':
setDiff(msg.diff)
return
case 'focusPathAsk':
setPathFocus(n => n + 1)
}
})
postMessage({ type: 'ready' })
@@ -191,6 +198,25 @@ export function App(): ReactElement | null {
/>
{changes && diff && <OverviewRuler stops={stops} lineCount={diff.lineCount} onPick={nav.goTo} />}
<div className="mx-auto box-content max-w-[880px] px-6 py-5">
{qa.state.available && (
<div className="mb-4">
<AskThread
history={historyOf(qa.state, PATH_ASK_ANCHOR)}
asks={asksOf(qa.state, PATH_ASK_ANCHOR)}
open
hint={qa.state.subject === 'folder' ? '这个文件夹' : '这个文件'}
autoFocus={false}
focusToken={pathFocus}
closable={false}
profiles={qa.state.profiles}
onSelectProfile={qa.onSelectProfile}
onAsk={(question, images) => qa.onAsk(PATH_ASK_ANCHOR, question, images)}
onStop={qa.onStop}
onDelete={entryId => qa.onDelete(PATH_ASK_ANCHOR, entryId)}
onClose={() => undefined}
/>
</div>
)}
{diff?.newFile !== undefined && doc.error === undefined && <NewFileBanner detail={diff.newFile} />}
{doc.error !== undefined && (
<div className="mb-3 flex items-start gap-2 rounded-lg border border-[var(--vscode-inputValidation-errorBorder)] bg-[var(--vscode-inputValidation-errorBackground)] px-3 py-2">
@@ -205,7 +231,7 @@ export function App(): ReactElement | null {
</div>
)}
{doc.blocks.length === 0
? doc.error === undefined && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>
? doc.error === undefined && qa.state.subject !== 'folder' && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>
: doc.blocks.some(block => !filter.hidden.has(block.id))
? <BlockLevel blocks={doc.blocks} tree={tree} />
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
+33 -6
View File
@@ -138,22 +138,38 @@ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url
}
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */
function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, onClose, onOpenImage }: {
function AskInput({ running, first, hint, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
running: Ask | undefined
/** 这块还没问过:提示「问这块代码…」,问过了是「继续问…」 */
/** 还没问过:提示「问…」,问过了是「继续问…」 */
first: boolean
/** 「问」后面的对象:块下面是「这块代码」,顶部是「这个文件」或「这个文件夹」 */
hint: string
profiles: ProfileChoice
onSelectProfile: (id: string) => void
onAsk: (question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onClose: () => void
/** Esc 收起。顶部那条一直摊开,不收 */
closable: boolean
onOpenImage: (url: string) => void
/** 展开时把光标放进输入框。顶部那条一直摊开,不要抢阅读时光标 */
autoFocus: boolean
/** 大于 0 时再放一次光标;侧栏「问 AI」打开已经在的预览时用 */
focusToken: number
}): ReactElement {
const [value, setValue] = useState('')
const [images, setImages] = useState<PendingImage[]>([])
/** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */
const [notice, setNotice] = useState<string>()
const textarea = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (autoFocus)
textarea.current?.focus()
}, [autoFocus])
useEffect(() => {
if (focusToken > 0)
textarea.current?.focus()
}, [focusToken])
const attach = (files: File[]): void => {
if (files.length === 0)
return
@@ -179,7 +195,7 @@ function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, on
event.preventDefault()
send()
}
else if (event.key === 'Escape') {
else if (closable && event.key === 'Escape') {
event.preventDefault()
onClose()
}
@@ -211,9 +227,8 @@ function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, on
<textarea
ref={textarea}
rows={1}
autoFocus
value={value}
placeholder={`${first ? '问这块代码…' : '继续问…'} Enter 发送,Esc 收起`}
placeholder={`${first ? `问${hint}…` : '继续问…'} Enter 发送${closable ? ',Esc 收起' : ''}`}
aria-label="问 AI 的问题"
onChange={event => setValue(event.target.value)}
onKeyDown={onKeyDown}
@@ -251,6 +266,14 @@ export interface AskThreadProps {
open: boolean
/** 输入框旁边的 profile 下拉 */
profiles: ProfileChoice
/** 「问」后面的对象,默认「这块代码」 */
hint?: string
/** 展开时聚焦输入框,默认聚焦。一直摊开的顶部聊天不聚焦 */
autoFocus?: boolean
/** 变大时把光标放进输入框 */
focusToken?: number
/** Esc 能不能收起,默认能。顶部聊天不收 */
closable?: boolean
now?: number
onSelectProfile: (id: string) => void
/** images 是贴进输入框的图片,随问题一起发 */
@@ -264,7 +287,7 @@ export interface AskThreadProps {
* 块下面的问答区(左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
* 没展开但问过的块是一行「问过 N 次」、下面默认摊开灰字历史,点这一行收起 / 再摊开,读代码时一眼看到这里问过什么。
*/
export function AskThread({ history, asks, open, profiles, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
export function AskThread({ history, asks, open, profiles, hint = '这块代码', autoFocus = true, focusToken = 0, closable = true, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
const [showHistory, setShowHistory] = useState(true)
/** 正在看大图的那张 */
const [preview, setPreview] = useState<string>()
@@ -295,10 +318,14 @@ export function AskThread({ history, asks, open, profiles, now = Date.now(), onS
first={history.length === 0 && asks.length === 0}
profiles={profiles}
onSelectProfile={onSelectProfile}
hint={hint}
onAsk={onAsk}
onStop={onStop}
onClose={onClose}
closable={closable}
onOpenImage={setPreview}
autoFocus={autoFocus}
focusToken={focusToken}
/>
{lightbox}
</section>
+20 -2
View File
@@ -1,7 +1,7 @@
import type { QaAction, QaState } from './qa'
import type { QaMessage, QaThreadView } from './types'
import { describe, expect, it } from 'vitest'
import { answerPreview, asksOf, EMPTY_QA, historyOf, qaReducer, relativeTime } from './qa'
import { answerPreview, asksOf, EMPTY_QA, historyOf, PATH_ASK_ANCHOR, qaReducer, relativeTime } from './qa'
const IF = 'function get_user > if @3fa1c2d0'
@@ -10,7 +10,7 @@ function thread(anchor: string, ...ids: string[]): QaThreadView {
}
function message(threads: QaThreadView[], running: QaMessage['running'] = []): QaMessage {
return { type: 'qa', available: true, threads, running }
return { type: 'qa', available: true, threads, path: [], subject: 'file', running }
}
function qa(threads: QaThreadView[], running: QaMessage['running'] = []): QaAction {
@@ -42,6 +42,24 @@ describe('问 AI 的状态', () => {
expect(state.threads).toHaveLength(1)
})
it('顶部的问答记在路径上,不进任何一块的历史', () => {
const state = run({
type: 'qa',
message: {
type: 'qa',
available: true,
threads: [],
path: [{ id: 'p', question: '这文件夹干什么', answer: '放源码', createdAt: '2026-09-25T10:00:00.000Z' }],
subject: 'folder',
running: [],
},
})
expect(state.path.map(entry => entry.id)).toEqual(['p'])
expect(state.subject).toBe('folder')
expect(historyOf(state, PATH_ASK_ANCHOR).map(entry => entry.id)).toEqual(['p'])
expect(historyOf(state, IF)).toEqual([])
})
it('收起就结束这一轮:答完的回到历史,还在答的留着;再展开', () => {
let state = run(
qa([thread(IF, 'e1')]),
+25 -4
View File
@@ -1,6 +1,7 @@
/**
* 「问 AI」在页面上的状态和规则(不碰 DOM):问答按块的记号(BlockView.anchor)挂到块下面;
* 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史;记号对不上任何块的问答不显示。
* 「问 AI」在页面上的状态和规则(不碰 DOM):块下面的问答按块的记号挂;
* 顶部的问答按路径记在 path 上,不挂到任何一块。
* 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史。
*/
import type { AnswerEnd, ProfileChoice, QaEntryView, QaMessage, QaThreadView } from './types'
@@ -23,10 +24,17 @@ export interface Ask {
entryId?: string
}
/** 顶部问答的记号。跟扩展端 pathQa 里的是同一个,块的记号不会是这个。 */
export const PATH_ASK_ANCHOR = '\0path'
export interface QaState {
/** 文件不在工作区文件夹里时问答没地方存,不给「问 AI」 */
available: boolean
threads: QaThreadView[]
/** 这一页顶部按路径记的问答 */
path: QaEntryView[]
/** 顶部问的是这个文件还是这个文件夹 */
subject: 'file' | 'folder'
/** 当前这一轮问的,按先后 */
asks: Ask[]
/** 展开着对话区(历史、当前这一轮、输入框)的块 */
@@ -35,7 +43,15 @@ export interface QaState {
profiles: ProfileChoice
}
export const EMPTY_QA: QaState = { available: false, threads: [], asks: [], open: [], profiles: { options: [], selected: '' } }
export const EMPTY_QA: QaState = {
available: false,
threads: [],
path: [],
subject: 'file',
asks: [],
open: [],
profiles: { options: [], selected: '' },
}
export type QaAction
= | { type: 'qa', message: QaMessage }
@@ -60,6 +76,8 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
...state,
available: action.message.available,
threads: action.message.threads,
path: action.message.path,
subject: action.message.subject,
asks: [...state.asks, ...adopted.map((ask): Ask => ({ ...ask, status: 'running' }))],
open: adopted.reduce((open, ask) => withOpen(open, ask.anchor), state.open),
}
@@ -108,7 +126,10 @@ export function asksOf(state: QaState, anchor: string): Ask[] {
/** anchor 那块的灰字历史:存下的问答,当前这一轮问的那几条除外 */
export function historyOf(state: QaState, anchor: string): QaEntryView[] {
const current = new Set(asksOf(state, anchor).map(ask => ask.entryId))
return state.threads.find(thread => thread.anchor === anchor)?.entries.filter(entry => !current.has(entry.id)) ?? []
const entries = anchor === PATH_ASK_ANCHOR
? state.path
: state.threads.find(thread => thread.anchor === anchor)?.entries ?? []
return entries.filter(entry => !current.has(entry.id))
}
const MINUTE = 60_000
@@ -266,6 +266,10 @@ export interface QaMessage {
type: 'qa'
available: boolean
threads: QaThreadView[]
/** 这一页顶部、按路径记的问答;不挂在块上 */
path: QaEntryView[]
/** 顶部问的是这个文件还是这个文件夹 */
subject: 'file' | 'folder'
running: RunningAsk[]
}
@@ -422,6 +426,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(扩展端给每行补回原来第一行的缩进)