✨ feat(vscode): 文件和文件夹在预览顶部问
侧栏「问 AI」打开代码阅读预览。文件夹是没有代码块的一页,源码文件在整页顶部聊,代码块下面的问答不动。历史按工作区文件夹和相对路径记在窗口状态里,不写进按块存的记录。
This commit is contained in:
1 parent
2be635d1e5
commit
2c10acf9c4
19 files changed
+610
-459
No files matched your search
@@ -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),
|
||||
))
|
||||
}
|
||||
Reference in new issue
Block a user