✨ feat(vscode): 定义卡片底部带上那块问过 AI 的问答,点问题展开回答

This commit is contained in:
cruldra committed 2026-10-15 19:59:51 +11:00
1 parent fdf7a35b18
commit b99659ea68
10 files changed
+336 -46

No files matched your search

+2
View File
@@ -112,6 +112,8 @@ export interface DefinitionPeek {
line: number
block: PageBlock
names: ShownName[]
/** 那块自己下面问过 AI 的各次,同那块下面的对话、按时间先后(子块上问的不算);没问过时没有 */
qa?: QaEntryView[]
}
export interface DocumentMessage {
@@ -4,8 +4,8 @@ import type { ProfileChoice } from '../qa/askProfiles'
import type { RewriteRequest } from '../qa/codeQa'
import type { SymbolNode } from '../symbolLinks'
import type { CodeColors } from '../theme/codeColors'
import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { describe, expect, it, vi } from 'vitest'
import type { ExtensionToWebview, QaEntryView, QaThreadView, WebviewToExtension } from './messages'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { commands, Range, Uri, window, workspace } from 'vscode'
import { createPythonAdapter } from '../languages/python'
import { runtime, WASM_DIR } from '../testSupport'
@@ -92,7 +92,7 @@ function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise<str
const uri = { fsPath: '/workspace/web/a.ts', scheme: 'file', toString: () => 'file:///workspace/web/a.ts' }
const document = { uri, isDirty: dirty, lineAt: (line: number) => ({ text: SOURCE[line] }), lineCount: SOURCE.length, eol: 1 }
vi.mocked(workspace.openTextDocument).mockResolvedValue(document as never)
const qa = { imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles: { ask: { subscribe }, rewrite: { subscribe } }, rewrite: vi.fn(rewrite) }
const qa = { imageRoots: () => [], onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles: { ask: { subscribe }, rewrite: { subscribe } }, rewrite: vi.fn(rewrite) }
const provider = new PreviewEditorProvider(...[
{},
{ onDidChange: subscribe },
@@ -223,12 +223,14 @@ function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }, co
const subscribe = () => ({ dispose: () => {} })
const diffBase = vi.fn((_uri: unknown, from: OpenedFrom): DiffBase | undefined => from === 'uncommitted' ? HEAD_BASE : undefined)
const qa = {
imageRoot: () => undefined,
imageRoots: () => [Uri.file('/workspace/.spx/code-qa'), Uri.file('/srv/app/.spx/code-qa')],
onDidChange: subscribe,
onDidAnswer: subscribe,
onDidRewrite: subscribe,
profiles,
view: () => ({ type: 'qa' }),
/** 默认哪块都没问过 */
blockThreads: vi.fn((_uri: Uri, _imageUrl: (file: Uri) => string): QaThreadView[] => []),
}
const provider = new PreviewEditorProvider(...[
{},
@@ -246,7 +248,7 @@ function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }, co
] as unknown as ConstructorParameters<typeof PreviewEditorProvider>)
const uri = { fsPath: '/workspace/web/a.ts', scheme: 'file', toString: () => 'file:///workspace/web/a.ts' }
const document = { uri, version: 1, languageId: 'typescript', fileName: '/workspace/web/a.ts', getText: () => SOURCE.join('\n') }
return { provider, document, diffBase }
return { provider, document, diffBase, qa }
}
/** 一个标签页:收发消息;切走、切回来时页面留着,VS Code 只报一声可见性变了 */
@@ -390,6 +392,8 @@ describe('页面能读哪些本地目录', () => {
const page = tab()
await provider.open(document.uri as never, { reveal: { line: 1 } }, async () => provider.resolveCustomTextEditor(document as never, page.panel))
expect(readableRoots(page)).toContain('/workspace')
// 各工作区文件夹的问答图片目录:定义卡片会带来别的工作区文件夹里的文件的问答图片
expect(readableRoots(page)).toEqual(expect.arrayContaining(['/workspace/.spx/code-qa', '/srv/app/.spx/code-qa']))
const options = page.panel.webview.options
await page.send({ type: 'ready' })
@@ -577,17 +581,18 @@ describe('问 AI 和重写注释的 profile 下拉各管各的', () => {
})
})
const HELPER = 'function helper'
/** 一块有名字的代码:名字在源码里的下标是 at */
function declared(anchor: string, at: number, children: BlockView[] = []): BlockView {
return { id: anchor, anchor, kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, defines: { startIndex: at, endIndex: at + 1 }, attributes: { kind: 'function' }, concepts: [], children, translation: [] }
}
function symbol(name: string, start: number, end: number, children: SymbolNode[] = []): SymbolNode {
return { name, range: { startIndex: start, endIndex: end }, selection: { startIndex: start, endIndex: start + name.length }, children }
}
describe('注释里不带路径的引用按它写在哪块的注释里找', () => {
const METHOD = 'class AgentService > function get_default_agent'
const HELPER = 'function helper'
/** 一块有名字的代码:名字在源码里的下标是 at */
function declared(anchor: string, at: number, children: BlockView[] = []): BlockView {
return { id: anchor, anchor, kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, defines: { startIndex: at, endIndex: at + 1 }, attributes: { kind: 'function' }, concepts: [], children, translation: [] }
}
function symbol(name: string, start: number, end: number, children: SymbolNode[] = []): SymbolNode {
return { name, range: { startIndex: start, endIndex: end }, selection: { startIndex: start, endIndex: start + name.length }, children }
}
it('同一个名字:方法说明里的找得到同类的另一个方法,顶层函数说明里的找不到;标坏链接、点了打开都按各自那块找', async () => {
const blocks = [declared('class AgentService', 100, [declared(METHOD, 400)]), declared(HELPER, 950)]
@@ -739,6 +744,111 @@ describe('注释里不带路径的引用按它写在哪块的注释里找', () =
})
})
describe('定义卡片带上那块自己问过 AI 的问答', () => {
const LOAD = 'function load'
const EVENTS = 'function useShellEvents'
const SPAWN = 'function spawn'
const OTHER = '/workspace/web/b.ts'
/** ⌘ 点进去的库源码,在工作区外 */
const LIBRARY = '/home/me/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.40.0/src/task/spawn.rs'
/** 本文件引用 spawn 的那条导入:在源码第 7 个字符,语言扩展答它定义在库源码里 */
const SPAWN_IMPORT = 7
const textDocument = (fsPath: string, languageId: string) => ({ uri: Uri.file(fsPath), version: 1, languageId, fileName: fsPath, getText: () => '', positionAt: (offset: number) => ({ line: 0, character: offset }), offsetAt: (position: { character: number }) => position.character })
const read = (blocks: BlockView[], imports: unknown[] = []) => ({
message: { type: 'document', version: 1, languageId: 'typescript', blocks, source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] },
displayNames: [],
symbols: [],
imports,
kinds: [],
bindings: [],
})
/** 问过的一次,回答照问题编 */
const asked = (question: string, images?: string[]): QaEntryView => ({ id: question, question, answer: `**答**:${question}`, createdAt: '2026-10-01T08:00:00.000Z', ...(images && { images }) })
/** 一块有名字的代码连同它下面一个没名字的子块(记号同 BlockAnchors:外层的记号 + 种类 + 源码哈希) */
const withBranch = (anchor: string, at: number): BlockView => declared(anchor, at, [declared(`${anchor} > if @3fa1c2d0`, at + 20)])
/**
* 三份文件:本文件(a.ts,有 load)、工作区里的别的文件(b.ts,有 useShellEvents)、工作区外的库源码(spawn.rs,有 spawn),
* 每份文件里那块和它的子块各有一块。`threads` 给每份文件块下面的问答(按文件路径),返回悬停引用时回的卡片。
*/
async function peeking(threads: (fsPath: string, imageUrl: (file: Uri) => string) => QaThreadView[]) {
const other = textDocument(OTHER, 'typescript')
const library = textDocument(LIBRARY, 'rust')
vi.mocked(documentMessage).mockImplementation(async input => (input.fileName === OTHER
? read([withBranch(EVENTS, 300)])
: input.fileName === LIBRARY
? read([withBranch(SPAWN, 6)])
: read([withBranch(LOAD, 100), declared(HELPER, 950)], [{ name: 'spawn', offset: SPAWN_IMPORT, range: { startIndex: 0, endIndex: 20 } }])) as never)
vi.mocked(documentSymbolNodes).mockReset().mockImplementation(async uri => uri === other.uri.toString()
? [symbol('useShellEvents', 300, 600)]
: [symbol('load', 100, 200), symbol('helper', 950, 990)])
vi.mocked(languageDefinitions).mockReset().mockImplementation((async (_uri: unknown, position: { character: number }) =>
position.character === SPAWN_IMPORT ? [{ uri: library.uri, range: new Range(0, 6, 0, 11) }] : []) as never)
const { provider, document, qa } = opening()
qa.blockThreads.mockImplementation((uri, imageUrl) => threads(uri.fsPath, imageUrl))
Object.assign(document, { positionAt: (offset: number) => ({ line: 0, character: offset }), offsetAt: (position: { character: number }) => position.character })
Object.assign(workspace, { textDocuments: [document, other, library] })
vi.mocked(workspace.openTextDocument).mockImplementation((async (uri: { toString: () => string }) =>
[other, library].find(doc => doc.uri.toString() === uri.toString()) ?? document) as never)
const page = tab()
provider.resolveCustomTextEditor(document as never, page.panel)
await page.send({ type: 'ready' })
await vi.waitFor(() => expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'document' })))
let id = 0
return async (link: { path?: string, symbol: string, block?: string }) => {
id += 1
await page.send({ type: 'peekLink', id, ...link })
const answer = page.posted().find(message => message.type === 'definitionPeeked' && message.id === id)
return answer?.type === 'definitionPeeked' ? answer.peek : undefined
}
}
const original = vi.mocked(documentMessage).getMockImplementation()!
afterEach(() => {
vi.mocked(documentMessage).mockImplementation(original)
vi.mocked(documentSymbolNodes).mockReset().mockResolvedValue([])
vi.mocked(languageDefinitions).mockReset().mockResolvedValue([])
Object.assign(workspace, { textDocuments: [] })
vi.mocked(workspace.openTextDocument).mockReset()
})
it('本文件的定义:带上那块自己的问答,按块下面的先后,图片换成页面能用的地址;子块上问的不带', async () => {
const peek = await peeking((fsPath, imageUrl) => fsPath === '/workspace/web/a.ts'
? [
{ anchor: LOAD, entries: [asked('load 读什么'), asked('为什么要缓存', [imageUrl(Uri.file('/workspace/.spx/code-qa/q2-1.png'))])] },
{ anchor: `${LOAD} > if @3fa1c2d0`, entries: [asked('这个条件什么时候成立')] },
]
: [])
const card = await peek({ symbol: 'load', block: HELPER })
expect(card?.block.anchor).toBe(LOAD)
expect(card?.qa).toEqual([
asked('load 读什么'),
asked('为什么要缓存', ['file:///workspace/.spx/code-qa/q2-1.png']),
])
})
it('别的文件、工作区外的库源码:按卡片那份文件和那块在它自己块树里的记号找,子块上问的不带', async () => {
const peek = await peeking(fsPath => [
{ anchor: EVENTS, entries: [asked(`${fsPath} 的 useShellEvents`)] },
{ anchor: SPAWN, entries: [asked(`${fsPath} 的 spawn`)] },
{ anchor: `${EVENTS} > if @3fa1c2d0`, entries: [asked('子块')] },
{ anchor: `${SPAWN} > if @3fa1c2d0`, entries: [asked('子块')] },
])
const other = await peek({ path: 'web/b.ts', symbol: 'useShellEvents' })
expect(other).toMatchObject({ uri: Uri.file(OTHER).toString(), qa: [asked(`${OTHER} 的 useShellEvents`)] })
const library = await peek({ symbol: 'spawn', block: HELPER })
expect(library).toMatchObject({ uri: Uri.file(LIBRARY).toString(), qa: [asked(`${LIBRARY} 的 spawn`)] })
})
it('那块没问过(只有子块问过、别的块问过):卡片不带问答', async () => {
const peek = await peeking(() => [{ anchor: `${LOAD} > if @3fa1c2d0`, entries: [asked('子块')] }, { anchor: HELPER, entries: [asked('别的块')] }])
const card = await peek({ symbol: 'load', block: HELPER })
expect(card?.block.anchor).toBe(LOAD)
expect(card).not.toHaveProperty('qa')
})
})
describe('点了带几行的仓库链接', () => {
/** 文件按名字认语言,第 n 行(0 起)有 n 个字,一共 20 行 */
function textDocument(fsPath: string) {
+24 -12
View File
@@ -1,11 +1,12 @@
import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { BlockView } from '../blockView'
import type { CommentEdit, CommentJob, WriteResult } from '../commentRewrite'
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange } from '../contract'
import type { OpenedFrom, PreviewDiffBases } from '../diff/diffBase'
import type { OldDocument, SentDocument } from '../diff/previewDiff'
import type { WorkspaceEntries } from '../entries/workspaceEntries'
import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution'
import type { DefinitionStart, IndexedName, NameSources, ShownName } from '../nameResolution'
import type { CodeQa, TopRequest } from '../qa/codeQa'
import type { CodeReaderRegistry } from '../registry'
import type { LookupSources } from '../ruleLookups'
@@ -196,14 +197,13 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
}
/**
* 预览页面能读的本地目录:页面自己的脚本和样式;问答带的图片所在的目录(工作区文件夹的 .spx/code-qa/),历史里才画得出缩略图;
* 注释、回答里的仓库图片所在的各个根(见 [linkBaseOf]),根的真实位置和这个路径不一样时两个都放,读不到真实位置就只放这个路径。
* 预览页面能读的本地目录:页面自己的脚本和样式;问答带的图片所在的目录(各工作区文件夹的 `.spx/code-qa/`,见 [CodeQa.imageRoots]),
* 历史和定义卡片里才画得出缩略图;注释、回答里的仓库图片所在的各个根(见 [linkBaseOf]),根的真实位置和这个路径不一样时两个都放,读不到真实位置就只放这个路径。
*/
private async readableRoots(uri: Uri): Promise<Uri[]> {
const imageRoot = this.qa.imageRoot(topSubjectOf(uri).uri)
const { roots } = await linkBaseOf(uri)
const repoRoots = await Promise.all(roots.map(async root => [root, await realpath(root).catch(() => root)]))
return [webviewUiRoot(this.extensionUri), ...(imageRoot ? [imageRoot] : []), ...[...new Set(repoRoots.flat())].map(root => Uri.file(root))]
return [webviewUiRoot(this.extensionUri), ...this.qa.imageRoots(), ...[...new Set(repoRoots.flat())].map(root => Uri.file(root))]
}
/** 「跳到下一处 / 上一处高亮」:发给能跳的那个预览,从命令面板运行时发给活动的预览。 */
@@ -659,7 +659,12 @@ class Preview {
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))
this.post(this.owner.qa.view(subject.uri, file => this.webviewUrl(file), subject.kind))
}
/** 本地文件 `file` 在这个预览页面里能直接用的地址(`webview.asWebviewUri`)。 */
private webviewUrl(file: Uri): string {
return this.panel.webview.asWebviewUri(file).toString()
}
/** 这一页顶部问的是什么(见 topSubjectOf)。 */
@@ -926,18 +931,16 @@ class Preview {
try {
const target = await workspace.openTextDocument(uri)
const line = target.positionAt(offset).line
const path = workspace.asRelativePath(uri)
const file = uri.toString()
if (file === this.document.uri.toString()) {
if (uri.toString() === this.document.uri.toString()) {
const { sent } = this
if (sent?.version !== target.version)
return undefined
const block = peekBlock(sent.blocks, offset, line)
return block && { path, uri: file, line, block: pagePeekBlock(block, sent.source), names: namesOnCard(block, this.names.known(target.version)) }
return block && this.peekCard(uri, line, block, sent.source, namesOnCard(block, this.names.known(target.version)))
}
const { message, displayNames } = await this.owner.readElsewhere(target)
const block = message.error === undefined ? peekBlock(message.blocks, offset, line) : undefined
return block && { path, uri: file, line, block: pagePeekBlock(block, message.source), names: namesOnCard(block, definitionSiteNames(displayNames)) }
return block && this.peekCard(uri, line, block, message.source, namesOnCard(block, definitionSiteNames(displayNames)))
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `没能读出 ${uri.toString()} 里定义的那块:${err instanceof Error ? err.message : String(err)}` })
@@ -945,6 +948,15 @@ class Preview {
}
}
/**
* `uri` 第 `line` 行的定义所在那块 `block`(`source` 是那份文件的源码)发给页面的卡片,带上 `names` 和这块自己下面问过 AI 的各次。
* 问答按那份文件和这块在那份文件块树里的记号(`block.anchor`,同那个文件的预览挂对话用的)找,子块上问的记号不同,不带。
*/
private peekCard(uri: Uri, line: number, block: BlockView, source: string, names: ShownName[]): DefinitionPeek {
const qa = this.owner.qa.blockThreads(uri, file => this.webviewUrl(file)).find(thread => thread.anchor === block.anchor)?.entries
return { path: workspace.asRelativePath(uri), uri: uri.toString(), line, block: pagePeekBlock(block, source), names, ...(qa && { qa }) }
}
/**
* 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开(新开的不比,已经开着的照旧),给了 spot 就滚过去、闪这处;
* 其余用 VS Code 默认的打开方式,给了 spot 就选中它(见 openingFor)。目标是否按「预览标签」打开听 VS Code 的设置,
@@ -1002,7 +1014,7 @@ class Preview {
const entry = await resolveRepoEntry(place.root, place.rel)
if (!entry || entry.isDirectory)
return { path: rel }
const src = this.panel.webview.asWebviewUri(Uri.file(entry.fsPath)).toString()
const src = this.webviewUrl(Uri.file(entry.fsPath))
return { path: rel, src }
}))
this.post({ type: 'repoImagesChecked', id, images })
+9
View File
@@ -29,6 +29,8 @@ vi.mock('vscode', async () => {
Disposable: { from: () => ({ dispose: () => {} }) },
Uri: { joinPath: (base: { fsPath: string }, ...parts: string[]) => ({ fsPath: [base.fsPath, ...parts].join('/') }) },
window: { showErrorMessage: vi.fn() },
/** 打开着的工作区文件夹(只有算问答图片目录时用;问答记在哪由下面 locateQa 的替身定) */
workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace' } }, { uri: { fsPath: '/srv/app' } }] },
}
})
@@ -494,6 +496,13 @@ describe('工作区外的库文件(⌘ 点进去的标准库源码)', () =>
})
})
describe('预览要能读到的问答图片目录', () => {
it('每个工作区文件夹的 .spx/code-qa/ 都放行:定义卡片会带来别的工作区文件夹里的文件的问答图片', () => {
const { qa } = service(context())
expect(qa.imageRoots().map(root => root.fsPath)).toEqual(['/workspace/.spx/code-qa', '/srv/app/.spx/code-qa'])
})
})
describe('没答完的那一轮也记着会话:接着说「继续」就接着它做', () => {
type RunLinesOptions = Parameters<typeof runClaudeLines>[0]
/** 假的 claude 每次起来干什么:吐出这些行(给了 error 再抛它),或一直不出声、等人叫停 */
+31 -11
View File
@@ -8,7 +8,7 @@ import type { AskEnd, RunningAsk } from './asks'
import type { Answer, AskRunner, CommentAnswer, CommentAsk } from './claudeAsk'
import type { ChatEntry, QaEntry, QaThread, RewriteEntry } from './qaMap'
import { randomUUID } from 'node:crypto'
import { Disposable, EventEmitter, Uri, window } from 'vscode'
import { Disposable, EventEmitter, Uri, window, workspace } from 'vscode'
import { getSettings, onDidChangeSettings } from '../../settings/store'
import { pathQaLocation, topQaLocation } from '../editor/pathPreview'
import { FolderStore, locate, locateQa } from '../folderStore'
@@ -96,6 +96,11 @@ function viewEntry(root: Uri | undefined, imageUrl: (file: Uri) => string, entry
}
}
/** 问过的一次发给预览的样子:带着回答,图片地址同 [viewEntry]。 */
function qaEntryView(root: Uri | undefined, imageUrl: (file: Uri) => string, entry: QaEntry): QaEntryView {
return { ...viewEntry(root, imageUrl, entry), answer: entry.answer }
}
/** 答完的一轮共有的几项:起 id、把带的图片写进 `<rootPath>/.spx/code-qa/`。图片写不进时抛错。 */
async function chatEntry(rootPath: string, question: string, profile: ClaudeProfile | undefined, images: readonly ClaudeImage[]): Promise<ChatEntry> {
const id = randomUUID()
@@ -174,41 +179,50 @@ export class CodeQa implements Disposable {
this.rewrites = new QaAsks(rewriter, REWRITE_POLICY)
}
/** uri 这份文件或文件夹的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);不在工作区文件夹里时没有。 */
imageRoot(uri: Uri): Uri | undefined {
const at = topQaLocation(uri)
return at && Uri.joinPath(at.folder.uri, ...QA_IMAGE_DIR.split('/'))
/**
* 预览要能读到的问答图片目录:每个工作区文件夹的 `.spx/code-qa/`。问答带的图片都存在某个工作区文件夹里,
* 一页上除了这份文件的,还画得出定义卡片带来的别的文件(可能在别的工作区文件夹)的问答图片。
*/
imageRoots(): Uri[] {
return (workspace.workspaceFolders ?? []).map(folder => Uri.joinPath(folder.uri, ...QA_IMAGE_DIR.split('/')))
}
/**
* 发给预览的问答和重写记录。块下面的按块的记号;顶部的按这条路径,subject 说是文件还是文件夹。
* 发给预览的问答和重写记录。块下面的按块的记号(见 [blockThreads]);顶部的按这条路径,subject 说是文件还是文件夹。
* 问答记在哪见 [locateQa]:工作区外的库文件也能问,没打开工作区文件夹时问 AI 不可用([QaMessage.available])。
* 重写注释会改文件本身,只给工作区里的文件([QaMessage.rewritable])。
* 带的图片换成预览里能用的地址(imageUrl,即 webview.asWebviewUri)。重写记录的回答是交回的正文,没交回时是空的。
*/
view(uri: Uri, imageUrl: (file: Uri) => string, subject: TopSubject): QaMessage {
const at = locateQa(uri)
const rewriteAt = locate(uri)
const top = topQaLocation(uri)
const threads = at ? this.store.get(at.folder, at.key) ?? [] : []
const rewrites = rewriteAt ? this.rewriteStore.get(rewriteAt.folder, rewriteAt.key) ?? [] : []
const pathEntries = top ? pathQaEntries(this.pathMap(), top.key) : []
const root = this.imageRoot(uri)
const show = (entry: QaEntry): QaEntryView => ({ ...viewEntry(root, imageUrl, entry), answer: entry.answer })
const showRewrite = (entry: RewriteEntry): QaEntryView => ({ ...viewEntry(root, imageUrl, entry), answer: entry.body ?? '', ...(entry.error && { error: entry.error }) })
return {
type: 'qa',
available: top !== undefined,
rewritable: rewriteAt !== undefined,
subject,
threads: threadViews(threads, show),
path: pathEntries.map(show),
threads: this.blockThreads(uri, imageUrl),
path: pathEntries.map(entry => qaEntryView(root, imageUrl, entry)),
current: this.asks.currentFor(uri.toString()),
rewrites: threadViews(rewrites, showRewrite),
rewriteCurrent: this.rewrites.currentFor(uri.toString()),
}
}
/**
* `uri` 这份文件块下面问过 AI 的,按块的记号,块下面的按时间先后:一页上画在各块下面,定义卡片里带上卡片那块的。
* 记在哪见 [locateQa](工作区外的库文件记在第一个工作区文件夹),没地方存时是空的;带的图片换成预览里能用的地址(`imageUrl`)。
*/
blockThreads(uri: Uri, imageUrl: (file: Uri) => string): QaThreadView[] {
const at = locateQa(uri)
const root = this.imageRoot(uri)
return threadViews(at ? this.store.get(at.folder, at.key) ?? [] : [], entry => qaEntryView(root, imageUrl, entry))
}
/**
* 开始问;问不了时返回原因(给人看的话)。在问答记着的工作区文件夹里跑 claude(见 [locateQa]),它读得到整个项目;
* 问的是工作区外的库文件时另外放行读库文件所在的目录。
@@ -472,6 +486,12 @@ export class CodeQa implements Disposable {
this.rewriteStore.dispose()
}
/** `uri` 这份文件或文件夹的问答带的图片放在哪个目录;不在工作区文件夹里时没有。 */
private imageRoot(uri: Uri): Uri | undefined {
const at = topQaLocation(uri)
return at && Uri.joinPath(at.folder.uri, ...QA_IMAGE_DIR.split('/'))
}
private pathMap() {
return parsePathQa(this.paths.get(PATH_QA_STATE_KEY))
}