🐛 fix(vscode): 切走标签再回来,调用树棵数不用重算,开着的弹框和选中的入口照旧
This commit is contained in:
1 parent
6cf053dcd0
commit
32018e71f1
9 files changed
+542
-110
No files matched your search
@@ -47,7 +47,7 @@ describe('调用树棵数在文档发出去之后算', () => {
|
||||
}
|
||||
const posted: { type: string, counts?: Record<string, number> }[] = []
|
||||
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<void>((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<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
@@ -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<string, Cached>()
|
||||
/** 这一轮数到的棵数,只记大于 0 的。后台数完、点开时当场问到,发的都是这一份 */
|
||||
private counts: Record<string, number> = {}
|
||||
/** 手上的结果是哪一版文档的;还没数过、预览关掉了是 undefined */
|
||||
private version: number | undefined
|
||||
/** 这一版里问过的块,按 anchor 记 */
|
||||
private readonly answers = new Map<string, Answer>()
|
||||
/** 后台正在数哪一版;没在数是 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Answer | undefined> {
|
||||
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<string, number> = {}
|
||||
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])
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ViewState>) => host.store!.update(patch),
|
||||
},
|
||||
}))
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let saved: ViewState | undefined
|
||||
|
||||
/** 页面(重新)加载:从 webview 存的状态起步 */
|
||||
async function load(): Promise<void> {
|
||||
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(<App />))
|
||||
}
|
||||
|
||||
async function unload(): Promise<void> {
|
||||
await act(async () => root.unmount())
|
||||
container.remove()
|
||||
host.listeners.clear()
|
||||
}
|
||||
|
||||
async function deliver(msg: ExtensionToWebview): Promise<void> {
|
||||
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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()
|
||||
})
|
||||
})
|
||||
@@ -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<number | undefined>(undefined)
|
||||
const [callCounts, setCallCounts] = useState<{ version: number, counts: Record<string, number> }>()
|
||||
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<OpenCall | null>(null)
|
||||
/** 页面重新加载前开着的调用树弹框:第一份文档到了,那块还在就重新打开;到之前记着的不动 */
|
||||
const restoreCall = useRef(viewState.get().callTree)
|
||||
/** 块树所在的页面内容:钉住卡片时在这里面量 */
|
||||
const content = useRef<HTMLDivElement>(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)
|
||||
|
||||
@@ -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('调用树弹框', () => {
|
||||
? (
|
||||
<>
|
||||
<div data-id="mask">卡片</div>
|
||||
<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={[ONE]} onClose={() => setOpen(false)} onJump={() => {}} />
|
||||
<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={[ONE]} selected={0} onSelect={() => {}} onClose={() => setOpen(false)} onJump={() => {}} />
|
||||
</>
|
||||
)
|
||||
: <div data-id="mask">卡片</div>
|
||||
@@ -191,21 +193,51 @@ describe('调用树弹框', () => {
|
||||
})
|
||||
|
||||
it('多于一棵树才有入口切换,点一项换成那棵;只有一棵没有这排', async () => {
|
||||
await render(<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={MANY} onClose={() => {}} onJump={() => {}} />)
|
||||
const picked: number[] = []
|
||||
function Page(): ReactElement {
|
||||
const [selected, setSelected] = useState(0)
|
||||
return (
|
||||
<CallTreeDialog
|
||||
kind="function"
|
||||
name="mask_phone"
|
||||
where="utils/phone.py:28"
|
||||
status="ready"
|
||||
trees={MANY}
|
||||
selected={selected}
|
||||
onSelect={(index) => {
|
||||
picked.push(index)
|
||||
setSelected(index)
|
||||
}}
|
||||
onClose={() => {}}
|
||||
onJump={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
await render(<Page />)
|
||||
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(<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={MANY} selected={1} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
|
||||
expect(selectedTab()).toBe('夜间对账1处')
|
||||
expect(document.body.querySelector('.cr-call-body')!.textContent).not.toContain('另外 2 处调用')
|
||||
await render(<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={MANY} selected={5} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
|
||||
expect(selectedTab()).toBe('夜间对账1处')
|
||||
})
|
||||
|
||||
it('正在查、没有调用者,以及类的「新建」节点', async () => {
|
||||
await render(<CallTreeDialog kind="class" name="" where="" status="loading" trees={[]} onClose={() => {}} onJump={() => {}} />)
|
||||
await render(<CallTreeDialog kind="class" name="" where="" status="loading" trees={[]} selected={0} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
|
||||
expect(document.body.textContent).toContain('正在查调用关系')
|
||||
await render(<CallTreeDialog kind="class" name="OrderService" where="billing/order.py:14" status="empty" trees={[]} onClose={() => {}} onJump={() => {}} />)
|
||||
await render(<CallTreeDialog kind="class" name="OrderService" where="billing/order.py:14" status="empty" trees={[]} selected={0} onSelect={() => {}} 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(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={{ files: ['web/src/lib/api/schema.d.ts', 'admin/src/lib/api/schema.d.ts'] }} onClose={() => {}} onJump={() => {}} />)
|
||||
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={{ files: ['web/src/lib/api/schema.d.ts', 'admin/src/lib/api/schema.d.ts'] }} selected={0} onSelect={() => {}} 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(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={{ files: [] }} onClose={() => {}} onJump={() => {}} />)
|
||||
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={{ files: [] }} selected={0} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
|
||||
expect(body()).toContain('工作区里没找到接口契约文件(openapi-typescript 生成的 .d.ts 或 openapi.json)')
|
||||
expect(body()).not.toContain('没有调用它的地方')
|
||||
|
||||
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="ready" trees={[ONE]} contractMiss={{ files: [] }} onClose={() => {}} onJump={() => {}} />)
|
||||
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="ready" trees={[ONE]} contractMiss={{ files: [] }} selected={0} onSelect={() => {}} 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(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={miss} onClose={() => {}} onJump={() => {}} />)
|
||||
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={miss} selected={0} onSelect={() => {}} 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(<CallTreeDialog kind="fastapi.route" name="get_default_agent" where="api/routes.py:79" status="ready" trees={[page]} onClose={() => {}} onJump={jump => jumps.push(jump)} />)
|
||||
await render(<CallTreeDialog kind="fastapi.route" name="get_default_agent" where="api/routes.py:79" status="ready" trees={[page]} selected={0} onSelect={() => {}} onClose={() => {}} onJump={jump => jumps.push(jump)} />)
|
||||
expect(document.body.textContent).toContain('页面 /my-agents')
|
||||
const rows = [...document.body.querySelectorAll<HTMLButtonElement>('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(<CallTreeDialog kind="function" name="run" where={where} status="ready" trees={[{ id: 'run', name: 'run', hits: 1, root: run }]} onClose={() => {}} onJump={() => {}} />)
|
||||
await render(<CallTreeDialog kind="function" name="run" where={where} status="ready" trees={[{ id: 'run', name: 'run', hits: 1, root: run }]} selected={0} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
|
||||
const title = document.body.querySelector('.cr-call-title')!
|
||||
const place = title.querySelector<HTMLElement>('[data-tooltip]')!
|
||||
expect(place.dataset.tooltip).toBe(where)
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<span>{item.name}</span>
|
||||
<span className="cr-call-hits">{`${item.hits} 处`}</span>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)默认收着:它折叠着就是整张不画。
|
||||
|
||||
Reference in new issue
Block a user