From 32018e71f1181a4d0fa16ba9c555fdf727eaa40a Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 13 Oct 2026 11:00:06 +1100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E5=88=87?= =?UTF-8?q?=E8=B5=B0=E6=A0=87=E7=AD=BE=E5=86=8D=E5=9B=9E=E6=9D=A5=EF=BC=8C?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=A0=91=E6=A3=B5=E6=95=B0=E4=B8=8D=E7=94=A8?= =?UTF-8?q?=E9=87=8D=E7=AE=97=EF=BC=8C=E5=BC=80=E7=9D=80=E7=9A=84=E5=BC=B9?= =?UTF-8?q?=E6=A1=86=E5=92=8C=E9=80=89=E4=B8=AD=E7=9A=84=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E7=85=A7=E6=97=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/codeReader/callTree/session.test.ts | 157 ++++++++++++++-- vscode/src/codeReader/callTree/session.ts | 169 +++++++++++------- vscode/src/codeReader/editor/previewEditor.ts | 5 +- vscode/webview-ui/src/codeReader/App.test.tsx | 158 ++++++++++++++++ vscode/webview-ui/src/codeReader/App.tsx | 64 +++++-- .../src/codeReader/CallTreeDialog.test.tsx | 54 ++++-- .../src/codeReader/CallTreeDialog.tsx | 18 +- .../src/codeReader/viewState.test.ts | 11 ++ vscode/webview-ui/src/codeReader/viewState.ts | 16 +- 9 files changed, 542 insertions(+), 110 deletions(-) create mode 100644 vscode/webview-ui/src/codeReader/App.test.tsx diff --git a/vscode/src/codeReader/callTree/session.test.ts b/vscode/src/codeReader/callTree/session.test.ts index b62e000..4f4c8f1 100644 --- a/vscode/src/codeReader/callTree/session.test.ts +++ b/vscode/src/codeReader/callTree/session.test.ts @@ -47,7 +47,7 @@ describe('调用树棵数在文档发出去之后算', () => { } const posted: { type: string, counts?: Record }[] = [] const session = new CallTreeSession(message => posted.push(message), () => source) - session.refresh() + session.documentSent() await viFlush() expect(posted).toEqual([{ type: 'callTreeCounts', version: 3, counts: { fn: 1, inner: 1 } }]) source = undefined @@ -72,9 +72,9 @@ describe('调用树棵数在文档发出去之后算', () => { } const posted: unknown[] = [] const session = new CallTreeSession(message => posted.push(message), () => source) - session.refresh() + session.documentSent() version = 2 - session.refresh() + session.documentSent() release!() await viFlush() expect(posted).toEqual([{ type: 'callTreeCounts', version: 2, counts: {} }]) @@ -94,7 +94,7 @@ describe('调用树棵数在文档发出去之后算', () => { const session = new CallTreeSession(message => posted.push(message), () => source) session.open(3, 'fn') expect(posted).toEqual([]) - session.refresh() + session.documentSent() await viFlush() posted.length = 0 session.open(4, 'fn') @@ -125,7 +125,7 @@ describe('调用树棵数在文档发出去之后算', () => { } const posted: unknown[] = [] const session = new CallTreeSession(message => posted.push(message), () => source) - session.refresh() + session.documentSent() await viFlush() expect(posted).toEqual([{ type: 'callTreeCounts', version: 5, counts: {} }]) posted.length = 0 @@ -159,7 +159,7 @@ describe('调用树棵数在文档发出去之后算', () => { } const posted: unknown[] = [] const session = new CallTreeSession(message => posted.push(message), () => source) - session.refresh() + session.documentSent() await viFlush() posted.length = 0 session.open(6, 'fn') @@ -188,9 +188,9 @@ describe('调用树棵数在文档发出去之后算', () => { } const posted: unknown[] = [] const session = new CallTreeSession(message => posted.push(message), () => source) + session.documentSent() session.open(7, 'fn') version = 8 - session.cancel() release!() await viFlush() expect(posted).toEqual([]) @@ -211,7 +211,7 @@ describe('调用树棵数在文档发出去之后算', () => { } const posted: unknown[] = [] const session = new CallTreeSession(message => posted.push(message), () => source) - session.refresh() + session.documentSent() await viFlush() expect(asked).toEqual([endpoint]) expect(posted).toEqual([{ type: 'callTreeCounts', version: 9, counts: { route: 1 } }]) @@ -222,14 +222,17 @@ describe('调用树棵数在文档发出去之后算', () => { const route = symbol('list_agents') const caller = symbol('nightly_job') let callers: CallEdge[] = [] + let version = 10 const source: CallTreeSource = { - version: 10, + get version() { + return version + }, blocks: [{ anchor: 'route', kind: 'fastapi.route', defines: { startIndex: 5 }, callTree: { endpoint: { method: 'GET', path: '/agents' } } }], load: async () => ({ symbol: route, edges: callers, contractMiss: { files: ['web/src/lib/api/schema.d.ts'] } }), } const posted: unknown[] = [] const session = new CallTreeSession(message => posted.push(message), () => source) - session.refresh() + session.documentSent() await viFlush() expect(posted).toEqual([{ type: 'callTreeCounts', version: 10, counts: {} }]) posted.length = 0 @@ -246,15 +249,145 @@ describe('调用树棵数在文档发出去之后算', () => { }]) callers = [{ caller, callee: route, at: { line: 1, character: 0, endLine: 1, endCharacter: 4 } }] - session.refresh() + version = 11 + session.documentSent() await viFlush() posted.length = 0 - session.open(10, 'route') + session.open(11, 'route') expect(posted).toEqual([expect.objectContaining({ trees: [expect.objectContaining({ name: 'nightly_job' })], contractMiss: { files: ['web/src/lib/api/schema.d.ts'] } })]) session.dispose() }) }) +describe('同一版文档又发一遍(页面重新加载、按新规则重发)', () => { + const mask = symbol('mask_phone') + const at = { line: 1, character: 0, endLine: 1, endCharacter: 4 } + const called = (callee: CallSymbol): CallEdge[] => [{ caller: symbol('get_profile'), callee, at }] + + it('问过的块不再问语言服务,没人调用的、语言不提供的也算问过;已知的棵数马上补发,点开直接回树', async () => { + const asked: string[] = [] + const source: CallTreeSource = { + version: 3, + blocks: [block('fn', 'function', 1), block('alone', 'function', 2), block('unknown', 'function', 3)], + load: async ({ anchor }) => { + asked.push(anchor) + if (anchor === 'unknown') + return undefined + return { symbol: mask, edges: anchor === 'fn' ? called(mask) : [] } + }, + } + const posted: unknown[] = [] + const session = new CallTreeSession(message => posted.push(message), () => source) + session.documentSent() + await viFlush() + expect(asked).toEqual(['fn', 'alone', 'unknown']) + expect(posted).toEqual([{ type: 'callTreeCounts', version: 3, counts: { fn: 1 } }]) + + posted.length = 0 + session.documentSent() + expect(posted).toEqual([{ type: 'callTreeCounts', version: 3, counts: { fn: 1 } }]) + await viFlush() + expect(asked).toEqual(['fn', 'alone', 'unknown']) + expect(posted).toHaveLength(1) + + posted.length = 0 + session.open(3, 'fn') + expect(posted).toEqual([expect.objectContaining({ type: 'callTree', version: 3, anchor: 'fn', trees: [expect.objectContaining({ name: 'get_profile' })] })]) + expect(asked).toHaveLength(3) + session.dispose() + }) + + it('后台还没数完页面就重新加载:先补发已经数到的,剩下的接着问、数完发齐,问过的不再问', async () => { + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + const asked: string[] = [] + const source: CallTreeSource = { + version: 4, + blocks: [block('first', 'function', 1), block('second', 'function', 2)], + load: async ({ anchor }) => { + asked.push(anchor) + if (anchor === 'second') + await gate + return { symbol: mask, edges: called(mask) } + }, + } + const posted: unknown[] = [] + const session = new CallTreeSession(message => posted.push(message), () => source) + session.documentSent() + await viFlush() + expect(posted).toEqual([]) + + session.documentSent() + expect(posted).toEqual([{ type: 'callTreeCounts', version: 4, counts: { first: 1 } }]) + release!() + await viFlush() + expect(posted).toEqual([ + { type: 'callTreeCounts', version: 4, counts: { first: 1 } }, + { type: 'callTreeCounts', version: 4, counts: { first: 1, second: 1 } }, + ]) + expect(asked).toEqual(['first', 'second']) + session.dispose() + }) + + it('版本变了才从头问,旧版本的棵数不带过去', async () => { + let version = 5 + let callers = true + const asked: number[] = [] + const source: CallTreeSource = { + get version() { + return version + }, + blocks: [block('fn', 'function', 1)], + load: async () => { + asked.push(version) + return { symbol: mask, edges: callers ? called(mask) : [] } + }, + } + const posted: unknown[] = [] + const session = new CallTreeSession(message => posted.push(message), () => source) + session.documentSent() + await viFlush() + posted.length = 0 + version = 6 + callers = false + session.documentSent() + expect(posted).toEqual([]) + await viFlush() + expect(asked).toEqual([5, 6]) + expect(posted).toEqual([{ type: 'callTreeCounts', version: 6, counts: {} }]) + session.dispose() + }) + + it('块的问法变了(库规则查到了接口的挂载前缀):只重问这一块,别的照旧', async () => { + const asked: unknown[] = [] + const route = (path: string): CallTreeBlock => ({ anchor: 'route', kind: 'fastapi.route', defines: { startIndex: 5 }, callTree: { endpoint: { method: 'GET', path } } }) + const source: { version: number, blocks: CallTreeBlock[], load: CallTreeSource['load'] } = { + version: 7, + blocks: [route('/agents'), block('fn', 'function', 1)], + load: async (target) => { + asked.push(target.callTree?.endpoint?.path ?? target.anchor) + return { symbol: mask, edges: target.callTree?.endpoint?.path === '/api/v1/agents' ? called(mask) : [] } + }, + } + const posted: unknown[] = [] + const session = new CallTreeSession(message => posted.push(message), () => source) + session.documentSent() + await viFlush() + source.blocks = [route('/api/v1/agents'), block('fn', 'function', 1)] + posted.length = 0 + session.documentSent() + await viFlush() + expect(asked).toEqual(['/agents', 'fn', '/api/v1/agents']) + expect(posted).toEqual([ + { type: 'callTreeCounts', version: 7, counts: {} }, + { type: 'callTreeCounts', version: 7, counts: { route: 1 } }, + ]) + session.dispose() + }) +}) + async function viFlush(): Promise { await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0)) diff --git a/vscode/src/codeReader/callTree/session.ts b/vscode/src/codeReader/callTree/session.ts index 30bdef9..ba8d9cd 100644 --- a/vscode/src/codeReader/callTree/session.ts +++ b/vscode/src/codeReader/callTree/session.ts @@ -1,8 +1,9 @@ /** - * 文档发出去之后,在后台数每个能看调用树的块(函数、类、接口卡片)落在几棵调用树里,有调用者的收起来,数完把棵数发给页面。 - * 页面点开某一块时,收着就把树发回去;没收着就当场问这一块,问到了补发棵数再发树,没问到发一棵空的; + * 文档发出去之后,在后台数每个能看调用树的块(函数、类、接口卡片)落在几棵调用树里,问到的都记下(没人调用的也记),数完把棵数发给页面。 + * 页面点开某一块时,问到过有调用者就把树发回去;没问过、或上回没人调用就当场问这一块,问到了补发棵数再发树,没问到发一棵空的; * 接口在契约文件里没对上时,发出去的树带着 contractMiss,页面照它说明,不当成没人调用。 - * 问的时候文档版本变了、或者停掉了,结果都丢掉。 + * 问到的结果跟着文档版本走:同一版文档又发一遍(标签页切回来页面重新加载、按新规则重发)时接着用,已知的棵数马上补发, + * 只问还没问过(或问法变了)的块;换了一版、预览关掉才丢掉。问的时候文档版本变了,结果也丢掉。 */ import type { CallTreeTarget } from '../blockView' @@ -38,102 +39,145 @@ interface Cached extends LoadedCalls { classTarget: boolean } -/** 一份文档的调用树:旧的查询作废,不挡住第一次预览。 */ +/** 问过的一块:按什么问的(见 targetOf)、落在几棵调用树里;语言不提供时没有 calls,棵数是 0。 */ +interface Answer { + target: string + count: number + calls?: Cached +} + +/** 一份文档的调用树:结果跟着文档版本走,旧版本的查询作废,不挡住第一次预览。 */ export class CallTreeSession { - private generation = 0 - private readonly cache = new Map() - /** 这一轮数到的棵数,只记大于 0 的。后台数完、点开时当场问到,发的都是这一份 */ - private counts: Record = {} + /** 手上的结果是哪一版文档的;还没数过、预览关掉了是 undefined */ + private version: number | undefined + /** 这一版里问过的块,按 anchor 记 */ + private readonly answers = new Map() + /** 后台正在数哪一版;没在数是 undefined */ + private scanning: number | undefined constructor( private readonly post: (message: ExtensionToWebview) => void, private readonly source: () => CallTreeSource | undefined, ) {} - /** 停掉还在跑的查询。文档又改了、预览关掉时用。 */ - cancel(): void { - this.generation++ - this.cache.clear() - this.counts = {} + /** 文档发出去了:还是手上这一版就接着用,换了一版就从头数。 */ + documentSent(): void { + const src = this.source() + if (!src) + return + if (src.version === this.version) + this.resume(src.version) + else + this.restart(src.version) } - /** 按眼下这份文档重新数。先停掉上一轮。 */ - refresh(): void { - this.cancel() - void this.scan(this.generation) - } - - /** 页面要看某一块。不是这一版文档的不理;收着就直接发;没收着就当场问这一块。 */ + /** 页面要看某一块。不是这一版文档的不理;问到过有调用者就直接发;没问到过、或上回没人调用,就当场问这一块。 */ open(version: number, anchor: string): void { const src = this.source() if (!src || src.version !== version) return - const cached = this.cache.get(anchor) - if (cached) { - this.postTree(version, anchor, cached) + const block = callBlocks(src.blocks).find(item => item.anchor === anchor) + const known = block && this.answerOf(block) + if (known?.calls && known.count > 0) { + this.postTree(version, anchor, known.calls) return } - void this.askNow(this.generation, src, anchor) + void this.askNow(version, anchor, block) } dispose(): void { - this.cancel() + this.version = undefined + this.answers.clear() } - private async scan(generation: number): Promise { - const src = this.source() - if (!src || generation !== this.generation) - return - const version = src.version - for (const block of callBlocks(src.blocks)) { - if (this.stale(generation, version)) - return - if (!this.cache.has(block.anchor)) - await this.collect(generation, src, block) - } - if (this.stale(generation, version)) - return + /** 同一版文档又发了一遍(页面重新加载、按新规则重发):之前发的页面可能没收到,先把已知的棵数补过去;后台没在数就接着数还没问过的块。 */ + private resume(version: number): void { this.postCounts(version) + if (this.scanning !== version) + void this.scan(version) + } + + /** 换了一版文档:之前问到的都作废,从头数。 */ + private restart(version: number): void { + this.version = version + this.answers.clear() + void this.scan(version) + } + + /** 一块一块问还没问过的,每问完一块按眼下发出去的块树再找下一块(数的时候文档可能又按同一版重发了);问了才在数完时发棵数。 */ + private async scan(version: number): Promise { + this.scanning = version + let asked = false + try { + for (;;) { + const src = this.source() + if (!src || this.stale(version)) + return + const next = callBlocks(src.blocks).find(block => !this.answerOf(block)) + if (!next) + break + asked = true + await this.collect(version, src, next) + } + if (asked) + this.postCounts(version) + } + finally { + if (this.scanning === version) + this.scanning = undefined + } } /** 当场问页面点开的那一块:问到了先补发棵数再发树,没问到(找不到这块、没人调用)发一棵空的;接口在契约里没对上时发带 contractMiss 的树。 */ - private async askNow(generation: number, src: CallTreeSource, anchor: string): Promise { - const version = src.version - const block = callBlocks(src.blocks).find(item => item.anchor === anchor) - const found = block && await this.collect(generation, src, block) - if (this.stale(generation, version)) + private async askNow(version: number, anchor: string, block: CallTreeBlock | undefined): Promise { + const src = this.source() + const found = block && src && await this.collect(version, src, block) + if (this.stale(version)) return - if (!found || (found.count === 0 && !found.cached.contractMiss)) { + if (!found?.calls || (found.count === 0 && !found.calls.contractMiss)) { this.postEmpty(version, anchor) return } if (found.count > 0) this.postCounts(version) - this.postTree(version, anchor, found.cached) + this.postTree(version, anchor, found.calls) } - /** 问一块有谁调用,连同棵数交回来;有调用者的收起来、记上棵数。语言不提供、问到一半作废了交回空。 */ - private async collect(generation: number, src: CallTreeSource, block: CallTreeBlock): Promise<{ cached: Cached, count: number } | undefined> { - const version = src.version - const loaded = await src.load(block, () => generation !== this.generation) - if (!loaded || this.stale(generation, version)) + /** 问一块有谁调用,记下问法和棵数再交回来;没人调用、语言不提供的也记下。问到一半作废了交回空、不记。 */ + private async collect(version: number, src: CallTreeSource, block: CallTreeBlock): Promise { + const loaded = await src.load(block, () => this.stale(version)) + if (this.stale(version)) return undefined - const cached: Cached = { ...loaded, classTarget: block.kind === 'class' } - const count = buildCallTrees(cached.edges, cached.symbol, { classTarget: cached.classTarget }).length - if (count > 0) { - this.cache.set(block.anchor, cached) - this.counts[block.anchor] = count + const calls = loaded && { ...loaded, classTarget: block.kind === 'class' } + const answer: Answer = { + target: targetOf(block), + count: calls ? buildCallTrees(calls.edges, calls.symbol, { classTarget: calls.classTarget }).length : 0, + ...(calls && { calls }), } - return { cached, count } + this.answers.set(block.anchor, answer) + return answer } - /** 这一轮停掉了,或者文档版本变了:手上的结果不能再用。 */ - private stale(generation: number, version: number): boolean { - return generation !== this.generation || this.source()?.version !== version + /** 这一块按眼下的问法问过的结果;没问过、问法变了(库规则查到的接口变了)是 undefined。 */ + private answerOf(block: CallTreeBlock): Answer | undefined { + const answer = this.answers.get(block.anchor) + return answer?.target === targetOf(block) ? answer : undefined } + /** 手上的结果不是 version 这一版的了,或者文档又改了:问到的不能再用。 */ + private stale(version: number): boolean { + return this.version !== version || this.source()?.version !== version + } + + /** 眼下这份块树里有调用者的块各落在几棵树里。 */ private postCounts(version: number): void { - this.post({ type: 'callTreeCounts', version, counts: { ...this.counts } }) + const counts: Record = {} + for (const block of callBlocks(this.source()?.blocks ?? [])) { + const count = this.answerOf(block)?.count ?? 0 + if (count > 0) + counts[block.anchor] = count + } + this.post({ type: 'callTreeCounts', version, counts }) } private postTree(version: number, anchor: string, cached: Cached): void { @@ -168,3 +212,8 @@ function callBlocks(blocks: readonly CallTreeBlock[]): CallTreeBlock[] { walk(blocks) return found } + +/** 一块怎么问:名字起点和接口。同一版文档按新规则重发时接口可能变了(查到了挂载前缀),变了就得重问。 */ +function targetOf(block: CallTreeBlock): string { + return JSON.stringify([block.defines?.startIndex, block.callTree?.endpoint]) +} diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index ea735b8..0a245c0 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -185,7 +185,7 @@ class Preview { visibleLine: number | undefined /** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */ private sent: SentDocument | undefined - /** 能看调用树的块(函数、类、接口卡片)落在几棵调用树里。文档发出去之后再数,不挡住第一次预览 */ + /** 能看调用树的块(函数、类、接口卡片)落在几棵调用树里。文档发出去之后再数,不挡住第一次预览;同一版文档数过的页面重新加载后接着用 */ private readonly callTrees = new CallTreeSession( message => this.post(message), () => { @@ -358,7 +358,6 @@ class Preview { if (!this.ready) return const request = ++this.latest - this.callTrees.cancel() // 路径页、功能对话页:文档是空的,只在顶部聊天 if (this.document.uri.scheme === PATH_PREVIEW_SCHEME) { this.sent = undefined @@ -401,7 +400,7 @@ class Preview { this.names.resolve(version, report => resolveNames(displayNames, message.blocks, this.nameSources(), report)) this.lookups.resolve(version, session.requests(), this.lookupSources()) this.callees.resolve(version, callees.requests(), offset => this.calleeKind(offset)) - this.callTrees.refresh() + this.callTrees.documentSent() } } diff --git a/vscode/webview-ui/src/codeReader/App.test.tsx b/vscode/webview-ui/src/codeReader/App.test.tsx new file mode 100644 index 0000000..f5ea87c --- /dev/null +++ b/vscode/webview-ui/src/codeReader/App.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment happy-dom + +import type { Root } from 'react-dom/client' +import type { BlockView, CallTree, DocumentMessage, ExtensionToWebview, WebviewToExtension } from './types' +import type { ViewState, ViewStateStore } from './viewState' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { App } from './App' +import { createViewStateStore } from './viewState' + +/** 扩展端那头:页面发出去的消息、页面挂着的收消息回调,以及 webview 自己存的状态(标签页切回来页面重新加载时还在) */ +const host = vi.hoisted(() => ({ + posted: [] as unknown[], + listeners: new Set<(msg: unknown) => void>(), + store: undefined as ViewStateStore | undefined, +})) + +vi.mock('./vscode', () => ({ + postMessage: (msg: WebviewToExtension) => host.posted.push(msg), + onMessage: (handler: (msg: unknown) => void) => { + host.listeners.add(handler) + return () => host.listeners.delete(handler) + }, + viewState: { + get: () => host.store!.get(), + update: (patch: Partial) => host.store!.update(patch), + }, +})) + +let container: HTMLDivElement +let root: Root +let saved: ViewState | undefined + +/** 页面(重新)加载:从 webview 存的状态起步 */ +async function load(): Promise { + host.store = createViewStateStore({ + getState: () => saved, + setState: (state) => { + saved = JSON.parse(JSON.stringify(state)) as ViewState + }, + }) + host.posted.length = 0 + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + await act(async () => root.render()) +} + +async function unload(): Promise { + await act(async () => root.unmount()) + container.remove() + host.listeners.clear() +} + +async function deliver(msg: ExtensionToWebview): Promise { + await act(async () => { + for (const listener of host.listeners) + listener(msg) + }) +} + +function block(id: string, anchor: string): BlockView { + return { + id, + anchor, + kind: 'function', + callTree: {}, + startLine: 0, + endLine: 1, + code: '', + codeTokens: [], + attributes: { kind: 'function' }, + concepts: [{ id: 'function', label: 'function', icon: 'braces' }], + children: [], + translation: [{ text: anchor, role: 'plain' }], + } +} + +function documentOf(version: number, blocks: BlockView[]): DocumentMessage { + return { type: 'document', version, languageId: 'python', blocks, attributes: [], translators: [], translatorId: '', names: [] } +} + +function tree(id: string, name: string): CallTree { + return { id, name, hits: 1, root: { name, kind: 'function', where: `${name}.py:1`, jump: { uri: `file:///${name}.py`, line: 0, character: 0 }, current: false, children: [] } } +} + +const TREES = [tree('read', '读取用户资料'), tree('night', '夜间对账')] +const dialog = () => document.body.querySelector('[role="dialog"]') +const selectedTab = () => document.body.querySelector('[role="tab"][aria-selected="true"]')?.textContent?.replace(/\s+/g, '') + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + saved = undefined +}) + +afterEach(async () => { + await unload() + vi.unstubAllGlobals() +}) + +describe('标签页切走再切回来,调用树弹框照旧开着', () => { + it('弹框开着、选了第二个入口:页面重新加载拿到文档后重新打开那块、要它的树,停回第二个入口;关掉后再加载就不开了', async () => { + await load() + await deliver(documentOf(3, [block('0', 'mask_phone')])) + await act(async () => document.body.querySelector('[data-call-tree="mask_phone"]')!.click()) + expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' }) + await deliver({ type: 'callTree', version: 3, anchor: 'mask_phone', name: 'mask_phone', where: 'utils/phone.py:28', trees: TREES }) + await act(async () => document.body.querySelectorAll('[role="tab"]')[1].click()) + expect(saved?.callTree).toEqual({ anchor: 'mask_phone', entry: 1 }) + + await unload() + await load() + expect(dialog()).toBeNull() + await deliver(documentOf(3, [block('0', 'mask_phone')])) + expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' }) + expect(dialog()!.textContent).toContain('正在查调用关系') + await deliver({ type: 'callTree', version: 3, anchor: 'mask_phone', name: 'mask_phone', where: 'utils/phone.py:28', trees: TREES }) + expect(selectedTab()).toBe('夜间对账1处') + + await act(async () => dialog()!.querySelector('[aria-label="关闭"]')!.click()) + expect(saved?.callTree).toBeUndefined() + await unload() + await load() + await deliver(documentOf(3, [block('0', 'mask_phone')])) + expect(dialog()).toBeNull() + expect(host.posted).not.toContainEqual(expect.objectContaining({ type: 'openCallTree' })) + }) + + it('页面重新加载前切走、文档还没到又切走一次,记着的弹框还在', async () => { + saved = { editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 1 } } + await load() + await unload() + await load() + await deliver(documentOf(3, [block('0', 'mask_phone')])) + expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' }) + expect(dialog()).not.toBeNull() + }) + + it('那块已经不在了:不开弹框、不要树,记录丢掉', async () => { + saved = { editing: [], folds: {}, callTree: { anchor: 'gone', entry: 0 } } + await load() + await deliver(documentOf(4, [block('0', 'mask_phone')])) + expect(dialog()).toBeNull() + expect(host.posted).not.toContainEqual(expect.objectContaining({ type: 'openCallTree' })) + expect(saved?.callTree).toBeUndefined() + }) + + it('弹框开着时文档换了一版:照旧关掉,记录跟着清掉', async () => { + saved = { editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 0 } } + await load() + await deliver(documentOf(5, [block('0', 'mask_phone')])) + expect(dialog()).not.toBeNull() + await deliver(documentOf(6, [block('0', 'mask_phone')])) + expect(dialog()).toBeNull() + expect(saved?.callTree).toBeUndefined() + }) +}) diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index fca1522..8bf785f 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -43,6 +43,28 @@ function savedBlockModes(): BlockModes { return { editing, folds } } +/** 开着的调用树弹框:哪一版文档的哪一块、选中第几个入口,扩展端发来的树 */ +interface OpenCall { + anchor: string + version: number + entry: number + status: 'loading' | 'ready' + name: string + where: string + kind: string + badge?: KindBadge + trees: CallTree[] + contractMiss?: ContractMiss +} + +/** 打开 doc 里 anchor 那块的调用树弹框,先是「正在查」,等扩展端发树来;这块不在了、或者它看不了调用树时是 undefined。 */ +function openingCall(doc: DocumentMessage, anchor: string, entry: number): OpenCall | undefined { + const block = blockAt(doc.blocks, anchor) + if (!block?.callTree) + return undefined + return { anchor, version: doc.version, entry, status: 'loading', name: '', where: '', kind: block.kind, ...(block.badge && { badge: block.badge }), trees: [] } +} + function blockAt(blocks: readonly BlockView[], anchor: string): BlockView | undefined { for (const block of blocks) { if (block.anchor === anchor) @@ -79,17 +101,9 @@ export function App(): ReactElement | null { /** 页面上这份文档的版本:调用树的回答不是这一版就丢掉 */ const docVersion = useRef(undefined) const [callCounts, setCallCounts] = useState<{ version: number, counts: Record }>() - const [openCall, setOpenCall] = useState<{ - anchor: string - version: number - status: 'loading' | 'ready' - name: string - where: string - kind: string - badge?: KindBadge - trees: CallTree[] - contractMiss?: ContractMiss - } | null>(null) + const [openCall, setOpenCall] = useState(null) + /** 页面重新加载前开着的调用树弹框:第一份文档到了,那块还在就重新打开;到之前记着的不动 */ + const restoreCall = useRef(viewState.get().callTree) /** 块树所在的页面内容:钉住卡片时在这里面量 */ const content = useRef(null) // 解析失败的文档没有块,不标差异;删掉的块按块筛选同样藏 @@ -138,16 +152,24 @@ export function App(): ReactElement | null { useEffect(() => { const off = onMessage((msg) => { switch (msg.type) { - case 'document': + case 'document': { + const restoring = restoreCall.current + restoreCall.current = undefined + const reopened = restoring && openingCall(msg, restoring.anchor, restoring.entry) docVersion.current = msg.version setDoc(msg) setNames(documentNames(msg.version, msg.names)) setKeep(prev => keptWhileEditing(prev, editing.current)) setCallCounts(prev => prev?.version === msg.version ? prev : undefined) - setOpenCall(prev => prev?.version === msg.version ? prev : null) + setOpenCall(prev => prev?.version === msg.version ? prev : reopened ?? null) + if (reopened) + postMessage({ type: 'openCallTree', version: msg.version, anchor: reopened.anchor }) + else if (restoring) + viewState.update({ callTree: undefined }) if (msg.reveal) setReveal(msg.reveal) return + } case 'callTreeCounts': if (msg.version !== docVersion.current) return @@ -195,13 +217,19 @@ export function App(): ReactElement | null { editing.current = view.editing }, [view]) + useEffect(() => { + if (restoreCall.current) + return + viewState.update({ callTree: openCall ? { anchor: openCall.anchor, entry: openCall.entry } : undefined }) + }, [openCall]) + const onFocused = useCallback(() => setFocusId(null), []) const onOpenCall = useCallback((anchor: string) => { - if (!doc) + const opening = doc && openingCall(doc, anchor, 0) + if (!opening) return - const block = blockAt(doc.blocks, anchor) - setOpenCall({ anchor, version: doc.version, status: 'loading', name: '', where: '', kind: block?.kind ?? 'function', ...(block?.badge && { badge: block.badge }), trees: [] }) - postMessage({ type: 'openCallTree', version: doc.version, anchor }) + setOpenCall(opening) + postMessage({ type: 'openCallTree', version: opening.version, anchor }) }, [doc]) const kindNames = useMemo(() => new Map(doc?.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])), [doc]) const kindName = useCallback((kind: string) => kindNames.get(kind) ?? kind, [kindNames]) @@ -315,7 +343,9 @@ export function App(): ReactElement | null { where={openCall.where} status={openCall.status === 'loading' ? 'loading' : openCall.trees.length === 0 ? 'empty' : 'ready'} trees={openCall.trees} + selected={openCall.entry} contractMiss={openCall.contractMiss} + onSelect={entry => setOpenCall(prev => prev && { ...prev, entry })} onClose={() => setOpenCall(null)} onJump={(jump) => { setOpenCall(null) diff --git a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx index e6ea7a4..ef8616c 100644 --- a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx +++ b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx @@ -140,6 +140,8 @@ describe('调用树弹框', () => { where="utils/phone.py:28" status="ready" trees={[ONE]} + selected={0} + onSelect={() => {}} onClose={() => setOpen(false)} onJump={() => {}} /> @@ -172,7 +174,7 @@ describe('调用树弹框', () => { ? ( <>
卡片
- setOpen(false)} onJump={() => {}} /> + {}} onClose={() => setOpen(false)} onJump={() => {}} /> ) :
卡片
@@ -191,21 +193,51 @@ describe('调用树弹框', () => { }) it('多于一棵树才有入口切换,点一项换成那棵;只有一棵没有这排', async () => { - await render( {}} onJump={() => {}} />) + const picked: number[] = [] + function Page(): ReactElement { + const [selected, setSelected] = useState(0) + return ( + { + picked.push(index) + setSelected(index) + }} + onClose={() => {}} + onJump={() => {}} + /> + ) + } + await render() const tabs = [...document.body.querySelectorAll('[role="tab"]')] expect(tabs.map(tab => tab.textContent?.replace(/\s+/g, ''))).toEqual(['读取用户资料2处', '夜间对账1处']) const body = () => document.body.querySelector('.cr-call-body')!.textContent ?? '' expect(body()).toContain('另外 2 处调用') expect(body()).not.toContain('夜间对账') await act(async () => (tabs[1] as HTMLButtonElement).click()) + expect(picked).toEqual([1]) expect(body()).toContain('夜间对账') expect(body()).not.toContain('另外 2 处调用') }) + it('选中第几个入口由外面给(页面重新加载后按记下的停回去);记下的比眼下的树多时停在最后一棵', async () => { + const selectedTab = () => document.body.querySelector('[role="tab"][aria-selected="true"]')?.textContent?.replace(/\s+/g, '') + await render( {}} onClose={() => {}} onJump={() => {}} />) + expect(selectedTab()).toBe('夜间对账1处') + expect(document.body.querySelector('.cr-call-body')!.textContent).not.toContain('另外 2 处调用') + await render( {}} onClose={() => {}} onJump={() => {}} />) + expect(selectedTab()).toBe('夜间对账1处') + }) + it('正在查、没有调用者,以及类的「新建」节点', async () => { - await render( {}} onJump={() => {}} />) + await render( {}} onClose={() => {}} onJump={() => {}} />) expect(document.body.textContent).toContain('正在查调用关系') - await render( {}} onJump={() => {}} />) + await render( {}} onClose={() => {}} onJump={() => {}} />) expect(document.body.textContent).toContain('没有调用它的地方') const made = node('新建 OrderService', { kind: 'class', @@ -220,6 +252,8 @@ describe('调用树弹框', () => { where="billing/order.py:14" status="ready" trees={[{ id: 'post', name: 'POST /orders', hits: 1, root: node('POST /orders', { detail: '入口', kind: 'function', children: [made] }) }]} + selected={0} + onSelect={() => {}} onClose={() => {}} onJump={() => {}} />, @@ -231,25 +265,25 @@ describe('调用树弹框', () => { expect(document.body.querySelector('[role="tablist"]')).toBeNull() }) it('接口在契约文件里没对上:写明重新生成、列出找到的契约文件;一份都没有时写没找到;都不说「没有调用它的地方」', async () => { - await render( {}} onJump={() => {}} />) + await render( {}} onClose={() => {}} onJump={() => {}} />) const body = () => document.body.querySelector('.cr-call-body')!.textContent ?? '' expect(body()).toContain('接口契约文件里没有这个接口,重新生成后再看') expect(body()).toContain('web/src/lib/api/schema.d.ts') expect(body()).toContain('admin/src/lib/api/schema.d.ts') expect(body()).not.toContain('没有调用它的地方') - await render( {}} onJump={() => {}} />) + await render( {}} onClose={() => {}} onJump={() => {}} />) expect(body()).toContain('工作区里没找到接口契约文件(openapi-typescript 生成的 .d.ts 或 openapi.json)') expect(body()).not.toContain('没有调用它的地方') - await render( {}} onJump={() => {}} />) + await render( {}} onClose={() => {}} onJump={() => {}} />) expect(body()).toContain('工作区里没找到接口契约文件') expect(body()).toContain('夜间对账') }) it('契约里几条都对得上、分不出是哪一条时列出那几条,不叫人重新生成', async () => { const miss = { files: ['web/src/lib/api/schema.d.ts'], ambiguous: ['/api/v1/admin/agents', '/api/v1/agents'] } - await render( {}} onJump={() => {}} />) + await render( {}} onClose={() => {}} onJump={() => {}} />) const body = document.body.querySelector('.cr-call-body')!.textContent ?? '' expect(body).toContain('接口契约文件里有几条都对得上这个接口,按 operationId 也分不出是哪一条') expect(body).toContain('/api/v1/admin/agents') @@ -267,7 +301,7 @@ describe('调用树弹框', () => { children: [node('get_default_agent', { current: true })], }) const page: CallTree = { id: 'page', name: 'MyAgentsPage', hits: 1, root: node('MyAgentsPage', { detail: '页面 /my-agents', children: [request] }) } - await render( {}} onJump={jump => jumps.push(jump)} />) + await render( {}} onClose={() => {}} onJump={jump => jumps.push(jump)} />) expect(document.body.textContent).toContain('页面 /my-agents') const rows = [...document.body.querySelectorAll('button.cr-call-card')] expect(rows.map(row => row.querySelector('.cr-call-name')!.textContent)).toEqual(['MyAgentsPage', '请求 GET /api/v1/agents/default', 'get_default_agent']) @@ -282,7 +316,7 @@ describe('调用树弹框', () => { where, children: [node('mask_phone', { current: true, where: 'phone.py:28' })], }) - await render( {}} onJump={() => {}} />) + await render( {}} onClose={() => {}} onJump={() => {}} />) const title = document.body.querySelector('.cr-call-title')! const place = title.querySelector('[data-tooltip]')! expect(place.dataset.tooltip).toBe(where) diff --git a/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx b/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx index 1d1cada..1b2958e 100644 --- a/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx +++ b/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx @@ -6,11 +6,11 @@ import type { ReactElement } from 'react' import type { CallJump, CallTree, CallTreeNode, ContractMiss, KindBadge } from './types' import { Box, Braces, Send, X } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import { createPortal } from 'react-dom' import { kindProps } from './BlockTree' -export function CallTreeDialog({ kind, badge, name, where, status, trees, contractMiss, onClose, onJump }: { +export function CallTreeDialog({ kind, badge, name, where, status, trees, selected, contractMiss, onSelect, onClose, onJump }: { /** 当前块的种类,整条路径用这个种类色;库规则的卡片按 badge 的颜色 */ kind: string badge?: KindBadge @@ -18,14 +18,18 @@ export function CallTreeDialog({ kind, badge, name, where, status, trees, contra where: string status: 'loading' | 'empty' | 'ready' trees: readonly CallTree[] + /** 选中第几个入口(从 0 起),页面记着它,重新加载后停回去;比眼下的树多时停在最后一棵 */ + selected: number /** 接口在契约文件里没对上时有 */ contractMiss?: ContractMiss + /** 点了另一个入口 */ + onSelect: (index: number) => void onClose: () => void /** 点了树上的一行 */ onJump: (jump: CallJump) => void }): ReactElement { - const [selected, setSelected] = useState(0) - const tree = trees[Math.min(selected, Math.max(trees.length - 1, 0))] + const current = Math.min(selected, Math.max(trees.length - 1, 0)) + const tree = trees[current] useEffect(() => { const onKey = (event: KeyboardEvent) => { @@ -73,10 +77,10 @@ export function CallTreeDialog({ kind, badge, name, where, status, trees, contra key={item.id} type="button" role="tab" - aria-selected={index === selected} - data-active={index === selected ? '' : undefined} + aria-selected={index === current} + data-active={index === current ? '' : undefined} className="cr-call-entry" - onClick={() => setSelected(index)} + onClick={() => onSelect(index)} > {item.name} {`${item.hits} 处`} diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index 4683a6c..4762bc5 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -70,6 +70,17 @@ describe('webview 状态', () => { expect(createViewStateStore(memoryStorage({ openedFrom: 'files' })).get()).toEqual({ editing: [], folds: {} }) }) + it('开着的调用树弹框(哪一块、选中第几个入口)页面重新加载后取回,关掉后就没了;形状不对的当没记过', () => { + const storage = memoryStorage() + const store = createViewStateStore(storage) + store.update({ callTree: { anchor: 'mask_phone', entry: 1 } }) + expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 1 } }) + store.update({ callTree: undefined }) + expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: {} }) + for (const callTree of ['mask_phone', { anchor: 1, entry: 0 }, { anchor: 'mask_phone' }, { anchor: 'mask_phone', entry: -1 }, { anchor: 'mask_phone', entry: 1.5 }]) + expect(createViewStateStore(memoryStorage({ callTree })).get()).toEqual({ editing: [], folds: {} }) + }) + it('属性行的折叠 id 是块 id 加行序号,不会和块 id 撞上', () => { expect(propertyRowId('6.1.0', 3)).toBe('6.1.0/p3') expect(propertyRowId('6', 1)).not.toBe('6.1') diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index 705e6a2..5258d25 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -3,7 +3,8 @@ import type { BlockView, OpenedFrom } from './types' /** * 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、 - * 用户把哪些块、属性行和子行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行、子行 id 见 [propertyRowId]、[propertyItemId]),以及滚到了哪。 + * 用户把哪些块、属性行和子行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行、子行 id 见 [propertyRowId]、[propertyItemId]),滚到了哪, + * 以及开着哪一块的调用树弹框、选中第几个入口。 * VS Code 重启后恢复标签页时也带着它,这个标签页是从哪儿打开的靠它报回扩展端。 */ export interface ViewState { @@ -13,6 +14,14 @@ export interface ViewState { scroll?: ScrollAnchor /** 这个标签页是从哪儿打开的,扩展端告诉的;新开的页面还没记 */ openedFrom?: OpenedFrom + /** 开着的调用树弹框;关着是没有 */ + callTree?: CallTreeView +} + +/** 开着的调用树弹框:哪一块(anchor),选中第几个入口(从 0 起)。 */ +export interface CallTreeView { + anchor: string + entry: number } export interface ViewStateStorage { @@ -59,9 +68,14 @@ function readViewState(saved: unknown): ViewState { folds, ...(isScrollAnchor(state.scroll) && { scroll: state.scroll }), ...((state.openedFrom === 'uncommitted' || state.openedFrom === 'other') && { openedFrom: state.openedFrom }), + ...(isCallTreeView(state.callTree) && { callTree: state.callTree }), } } +function isCallTreeView(value: unknown): value is CallTreeView { + return isRecord(value) && typeof value.anchor === 'string' && Number.isInteger(value.entry) && (value.entry as number) >= 0 +} + /** * 块默认折叠吗:函数、类、枚举、异常块和库规则的卡片收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 * 挂在父卡片子行上、点子行才展开的子块(BlockSlot)默认收着:它折叠着就是整张不画。