✨ feat(vscode): 定义卡片底部带上那块问过 AI 的问答,点问题展开回答
This commit is contained in:
1 parent
fdf7a35b18
commit
b99659ea68
10 files changed
+336
-46
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 再抛它),或一直不出声、等人叫停 */
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -82,8 +82,11 @@ function Thumbs({ urls, pending, onOpen, onRemove }: { urls: string[], pending?:
|
||||
)
|
||||
}
|
||||
|
||||
/** 看大图:盖住整个预览,点哪儿或按 Esc 关上;打开时焦点在「关闭」上,关上后回到点开它的地方。 */
|
||||
function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): ReactElement {
|
||||
/**
|
||||
* 看大图:盖住整个预览,点哪儿或按 Esc 关上;打开时焦点在「关闭」上,关上后回到点开它的地方。
|
||||
* 平时 portal 到 body;`inPlace` 时就画在原处(定义卡片浮窗里:指针在大图上也算在浮窗里,浮窗不收)。
|
||||
*/
|
||||
function ImagePreview({ url, inPlace = false, onClose }: { url: string, inPlace?: boolean, onClose: () => void }): ReactElement {
|
||||
const close = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => {
|
||||
const opener = document.activeElement
|
||||
@@ -101,15 +104,15 @@ function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): R
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
return createPortal(
|
||||
const lightbox = (
|
||||
<div className="cr-qa-lightbox" role="dialog" aria-modal="true" aria-label="图片大图" onClick={onClose} onKeyDown={onKeyDown}>
|
||||
<img src={url} alt="" />
|
||||
<button ref={close} type="button" className="cr-qa-lightbox-close" data-tooltip="关闭(Esc)" aria-label="关闭大图">
|
||||
<X size={16} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
</div>
|
||||
)
|
||||
return inPlace ? lightbox : createPortal(lightbox, document.body)
|
||||
}
|
||||
|
||||
/** 复制按钮在每种复制结果下的图标和名字。 */
|
||||
@@ -130,6 +133,11 @@ function CopyAnswerButton({ answer }: { answer: string }): ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
/** 说过的一次展开后的整段回答:按 Markdown 排,在历史里是灰字。 */
|
||||
function HistoryAnswer({ answer }: { answer: string }): ReactElement {
|
||||
return <MarkdownPreview html={renderMarkdown(answer, 'answer')} className="cr-qa-answer" />
|
||||
}
|
||||
|
||||
/**
|
||||
* 说过的一次,灰字:说的话、时间、复制回答、删除(问答叫「已理解,删除」),带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排)。
|
||||
* 重写记录没写进文件时,最下面写着原因
|
||||
@@ -158,7 +166,7 @@ function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: Qa
|
||||
</div>
|
||||
<Thumbs urls={entry.images ?? []} onOpen={onOpenImage} />
|
||||
{expanded
|
||||
? <MarkdownPreview html={renderMarkdown(entry.answer, 'answer')} className="cr-qa-answer" />
|
||||
? <HistoryAnswer answer={entry.answer} />
|
||||
: (
|
||||
<button type="button" className="cr-qa-preview" onClick={toggle}>
|
||||
{answerPreview(entry.answer)}
|
||||
@@ -174,6 +182,27 @@ function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: Qa
|
||||
)
|
||||
}
|
||||
|
||||
/** 只读列表里问过的一次,灰字:只有问题一行,点了在下面展开带的图片和整段回答(同 [HistoryItem] 展开后的),再点收起。 */
|
||||
function AskedItem({ entry, onOpenImage }: { entry: QaEntryView, onOpenImage: (url: string) => void }): ReactElement {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
return (
|
||||
<div className="cr-qa-entry" data-history="">
|
||||
<div className="cr-qa-head">
|
||||
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
|
||||
<button type="button" className="cr-qa-question" aria-expanded={expanded} data-tooltip={expanded ? '收起回答' : '展开回答'} onClick={() => setExpanded(value => !value)}>
|
||||
{entry.question}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<>
|
||||
<Thumbs urls={entry.images ?? []} onOpen={onOpenImage} />
|
||||
<HistoryAnswer answer={entry.answer} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 当前这一轮说的一次,照常颜色:说的话(答完了右边能复制回答),回答逐字长出来(按 Markdown 排),排着队、在做的事、出错(重写注释没写进文件也算)、停止写在下面 */
|
||||
function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadWording, onOpenImage: (url: string) => void }): ReactElement {
|
||||
const running = ask.status === 'running'
|
||||
@@ -425,3 +454,24 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码'
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读的问答列表(定义卡片底部那一节):一行「问过 AI(N)」,下面按 `entries` 的先后只列问题,点一条展开 / 收起它的回答;不能删、不能接着问。
|
||||
* 看大图画在列表自己里面(见 [ImagePreview] 的 `inPlace`),没问过时什么都不画。
|
||||
*/
|
||||
export function AskedList({ entries }: { entries: QaEntryView[] }): ReactElement | null {
|
||||
/** 正在看大图的那张 */
|
||||
const [preview, setPreview] = useState<string>()
|
||||
if (entries.length === 0)
|
||||
return null
|
||||
return (
|
||||
<section className="cr-qa" aria-label="问过 AI">
|
||||
<div className="cr-qa-title">
|
||||
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
|
||||
{`问过 AI(${entries.length})`}
|
||||
</div>
|
||||
{entries.map(entry => <AskedItem key={entry.id} entry={entry} onOpenImage={setPreview} />)}
|
||||
{preview !== undefined && <ImagePreview url={preview} inPlace onClose={() => setPreview(undefined)} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { BlockView, DefinitionPeek } from './types'
|
||||
import type { BlockView, DefinitionPeek, QaEntryView } from './types'
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { inPeek } from './definitionPeek'
|
||||
import { DefinitionPeekPopup, PeekTree } from './DefinitionPeekPopup'
|
||||
import { commentLinkOf } from './repoLink'
|
||||
import { NO_SPAN } from './testBlocks'
|
||||
@@ -69,6 +70,82 @@ describe('定义卡片浮窗里的卡片', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('定义卡片底部的「问过 AI」', () => {
|
||||
const QA: QaEntryView[] = [
|
||||
{ id: 'e1', question: 'spawn 返回的 JoinHandle 不 await 会怎样?', answer: '任务照样跑,只是**拿不到结果**。', createdAt: '2026-10-01T08:00:00.000Z' },
|
||||
{ id: 'e2', question: '这张图里的报错是什么意思?', answer: '`Send` 约束没满足。', createdAt: '2026-10-02T08:00:00.000Z', images: ['https://webview/q2-1.png'] },
|
||||
]
|
||||
|
||||
/** 弹出 peek 的浮窗,交给 check 看、点;看完收掉 */
|
||||
async function popup(peek: DefinitionPeek, check: () => Promise<void>): Promise<void> {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
||||
const anchor = document.createElement('span')
|
||||
document.body.append(anchor)
|
||||
const root = createRoot(document.createElement('div'))
|
||||
try {
|
||||
await act(async () => root.render(<DefinitionPeekPopup shown={{ anchor, peek, jump: { type: 'openRepoLink', symbol: NAME } }} onJump={() => {}} />))
|
||||
await check()
|
||||
}
|
||||
finally {
|
||||
await act(async () => root.unmount())
|
||||
anchor.remove()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
}
|
||||
const section = (): HTMLElement | null => document.querySelector<HTMLElement>('.cr-peek section[aria-label="问过 AI"]')
|
||||
const questions = (): HTMLButtonElement[] => [...section()!.querySelectorAll<HTMLButtonElement>('.cr-qa-question')]
|
||||
const click = (element: HTMLElement): Promise<void> => act(async () => element.click())
|
||||
|
||||
it('卡片下面一节「问过 AI(N)」:按块下面的先后只列问题,回答收着;只读,没有输入框、删除', async () => {
|
||||
await popup({ ...PEEK, qa: QA }, async () => {
|
||||
expect(section()!.textContent).toMatch(/^问过 AI(2)/)
|
||||
expect(questions().map(button => button.textContent)).toEqual([QA[0].question, QA[1].question])
|
||||
expect(questions().every(button => button.getAttribute('aria-expanded') === 'false')).toBe(true)
|
||||
expect(section()!.querySelector('.cr-qa-answer')).toBeNull()
|
||||
expect(section()!.querySelector('img')).toBeNull()
|
||||
expect(section()!.querySelector('textarea')).toBeNull()
|
||||
expect(section()!.querySelector('[aria-label="已理解,删除"]')).toBeNull()
|
||||
// 卡片在上、问答在下
|
||||
expect(document.querySelector('.cr-peek-body')!.lastElementChild).toBe(section())
|
||||
})
|
||||
})
|
||||
|
||||
it('点一条展开它的回答(同对话页:按 Markdown 排、带的图片),再点收起', async () => {
|
||||
await popup({ ...PEEK, qa: QA }, async () => {
|
||||
await click(questions()[1])
|
||||
expect(questions()[1].getAttribute('aria-expanded')).toBe('true')
|
||||
expect(section()!.querySelector('.cr-qa-answer')!.innerHTML).toBe('<p><code>Send</code> 约束没满足。</p>\n')
|
||||
expect(section()!.querySelector<HTMLImageElement>('.cr-qa-thumb img')!.getAttribute('src')).toBe('https://webview/q2-1.png')
|
||||
await click(questions()[0])
|
||||
expect(section()!.querySelectorAll('.cr-qa-answer')).toHaveLength(2)
|
||||
expect(section()!.querySelector('.cr-qa-answer')!.innerHTML).toContain('<strong>拿不到结果</strong>')
|
||||
await click(questions()[1])
|
||||
expect(questions()[1].getAttribute('aria-expanded')).toBe('false')
|
||||
expect(section()!.querySelectorAll('.cr-qa-answer')).toHaveLength(1)
|
||||
expect(section()!.querySelector('img')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('点缩略图看大图:大图画在浮窗里,指针移上去、点它都算在浮窗里,浮窗不收', async () => {
|
||||
await popup({ ...PEEK, qa: QA }, async () => {
|
||||
await click(questions()[1])
|
||||
await click(section()!.querySelector<HTMLButtonElement>('.cr-qa-thumb-open')!)
|
||||
const lightbox = document.querySelector<HTMLElement>('.cr-qa-lightbox')!
|
||||
expect(inPeek(lightbox)).toBe(true)
|
||||
await click(lightbox)
|
||||
expect(document.querySelector('.cr-qa-lightbox')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('那块没问过:没有这一节', async () => {
|
||||
await popup(PEEK, async () => {
|
||||
expect(document.querySelector('.cr-peek')).not.toBeNull()
|
||||
expect(section()).toBeNull()
|
||||
expect(document.querySelector('.cr-peek')!.textContent).not.toContain('问过 AI')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('定义卡片浮窗', () => {
|
||||
it('浮窗根上记着卡片那份文件:卡片注释里不带路径的引用带着它和写在的那块,扩展端按那份文件找', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ShownPeek } from './definitionPeek'
|
||||
import type { DefinitionPeek } from './types'
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AskedList } from './AskThread'
|
||||
import { BlockTree, treeState } from './BlockTree'
|
||||
import { PEEK_ATTRIBUTE, placePeek } from './definitionPeek'
|
||||
import { nameMap } from './displayNames'
|
||||
@@ -48,7 +49,7 @@ export function PeekTree({ peek }: { peek: DefinitionPeek }): ReactElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义卡片浮窗:标题一行「相对路径:行号」,点了跳到定义(同 ⌘ / Ctrl + 点击);下面是卡片,太大时在浮窗里滚。
|
||||
* 定义卡片浮窗:标题一行「相对路径:行号」,点了跳到定义(同 ⌘ / Ctrl + 点击);下面是卡片,卡片底下是那块自己问过 AI 的([AskedList],只读),太大时一起在浮窗里滚。
|
||||
* 外框用 VS Code 悬停框的颜色;portal 到 body,先在左上角量出大小,再落到名字下方或上方(placePeek)。
|
||||
* 根上记着卡片那份文件([PEEK_ATTRIBUTE]),卡片注释里不带路径的引用点了、标坏都按那份文件找。
|
||||
*/
|
||||
@@ -87,6 +88,7 @@ export function DefinitionPeekPopup({ shown, onJump }: { shown: ShownPeek<HTMLEl
|
||||
<button type="button" className="cr-peek-head" data-tooltip="跳到定义" onClick={onJump}>{where}</button>
|
||||
<div className="cr-peek-body">
|
||||
<PeekTree key={`${where}#${peek.block.id}`} peek={peek} />
|
||||
{peek.qa && <AskedList key={`${where}#${peek.block.id}`} entries={peek.qa} />}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -373,6 +373,10 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c
|
||||
overflow: auto;
|
||||
padding: 6px 8px 6px 0;
|
||||
}
|
||||
/* 卡片底下那块问过 AI 的:同块下面的问答区(左边一条竖线、灰字),离卡片空一点,左边不贴着浮窗边 */
|
||||
.cr-peek-body > .cr-qa {
|
||||
margin: 6px 0 0 8px;
|
||||
}
|
||||
|
||||
/* 按住 ⌘ / Ctrl 停在查得到定义的名字上:和编辑器里的链接一样 */
|
||||
.cr-code [data-link] {
|
||||
@@ -1017,7 +1021,9 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.cr-qa-summary {
|
||||
/* 「问过 N 次」那一行能点着收起 / 摊开;只读列表的标题「问过 AI(N)」同样子,不能点 */
|
||||
.cr-qa-summary,
|
||||
.cr-qa-title {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
|
||||
@@ -264,6 +264,8 @@ export interface DefinitionPeek {
|
||||
line: number
|
||||
block: BlockView
|
||||
names: ShownName[]
|
||||
/** 那块自己下面问过 AI 的各次,同那块下面的对话、按时间先后(子块上问的不算);没问过时没有 */
|
||||
qa?: QaEntryView[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user