✨ feat(vscode): 文件和文件夹在预览顶部问
侧栏「问 AI」打开代码阅读预览。文件夹是没有代码块的一页,源码文件在整页顶部聊,代码块下面的问答不动。历史按工作区文件夹和相对路径记在窗口状态里,不写进按块存的记录。
This commit is contained in:
1 parent
2be635d1e5
commit
2c10acf9c4
19 files changed
+610
-459
No files matched your search
@@ -76,6 +76,10 @@
|
||||
"selector": [
|
||||
{
|
||||
"filenamePattern": "*"
|
||||
},
|
||||
{
|
||||
"filenamePattern": "*",
|
||||
"scheme": "code-reader-path"
|
||||
}
|
||||
],
|
||||
"priority": "option"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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('这个问题已经在问了。')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
))
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')]),
|
||||
|
||||
@@ -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(扩展端给每行补回原来第一行的缩进)
|
||||
|
||||
Reference in new issue
Block a user