✨ 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 })
|
||||
|
||||
Reference in new issue
Block a user