🐛 fix(vscode): 调用树按钮一直显示,点开时没数到就当场再查

This commit is contained in:
cruldra committed 2026-10-11 22:49:42 +11:00
1 parent 6e8be1447a
commit 83d1f4a675
5 files changed
+155 -39

No files matched your search

@@ -100,6 +100,94 @@ describe('调用树棵数在文档发出去之后算', () => {
}])
session.dispose()
})
it('后台没数出调用者的块,点开时当场再问一次;这回问到了就发树,并补上棵数', async () => {
const mask = symbol('mask_phone')
const caller = symbol('get_profile')
const edge: CallEdge = { caller, callee: mask, at: { line: 2, character: 0, endLine: 2, endCharacter: 3 } }
let answering = false
let asked = 0
const source: CallTreeSource = {
version: 5,
blocks: [block('fn', 'function', 1)],
load: async () => {
asked++
return { symbol: mask, edges: answering ? [edge] : [] }
},
}
const posted: unknown[] = []
const session = new CallTreeSession(message => posted.push(message), () => source)
session.refresh()
await viFlush()
expect(posted).toEqual([{ type: 'callTreeCounts', version: 5, counts: {} }])
posted.length = 0
answering = true
session.open(5, 'fn')
await viFlush()
expect(asked).toBe(2)
expect(posted).toEqual([
{ type: 'callTreeCounts', version: 5, counts: { fn: 1 } },
{
type: 'callTree',
version: 5,
anchor: 'fn',
name: 'mask_phone',
where: 'mask_phone.py:1',
trees: [expect.objectContaining({ name: 'get_profile', hits: 1 })],
},
])
posted.length = 0
session.open(5, 'fn')
expect(asked).toBe(2)
expect(posted).toEqual([expect.objectContaining({ type: 'callTree', anchor: 'fn' })])
session.dispose()
})
it('点开时当场问还是没有调用者,就发一棵空的', async () => {
const source: CallTreeSource = {
version: 6,
blocks: [block('fn', 'function', 1)],
load: async () => ({ symbol: symbol('nobody'), edges: [] }),
}
const posted: unknown[] = []
const session = new CallTreeSession(message => posted.push(message), () => source)
session.refresh()
await viFlush()
posted.length = 0
session.open(6, 'fn')
await viFlush()
expect(posted).toEqual([{ type: 'callTree', version: 6, anchor: 'fn', name: '', where: '', trees: [] }])
session.dispose()
})
it('当场问到一半文档改了,结果不发', async () => {
let release: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
release = resolve
})
let version = 7
const mask = symbol('mask_phone')
const edge: CallEdge = { caller: symbol('get_profile'), callee: mask, at: { line: 2, character: 0, endLine: 2, endCharacter: 3 } }
const source: CallTreeSource = {
get version() {
return version
},
blocks: [block('fn', 'function', 1)],
load: async () => {
await gate
return { symbol: mask, edges: [edge] }
},
}
const posted: unknown[] = []
const session = new CallTreeSession(message => posted.push(message), () => source)
session.open(7, 'fn')
version = 8
session.cancel()
release!()
await viFlush()
expect(posted).toEqual([])
session.dispose()
})
})
async function viFlush(): Promise<void> {
+53 -29
View File
@@ -1,6 +1,7 @@
/**
* 文档发出去之后,在后台数每个函数、类落在几棵调用树里。
* 页面点开某一块时,把这一版已经收好的边收成树发回去。版本对不上就丢掉。
* 文档发出去之后,在后台数每个函数、类落在几棵调用树里,有调用者的收起来,数完把棵数发给页面。
* 页面点开某一块时,收着就把树发回去;没收着就当场问这一块,问到了补发棵数再发树,没问到发一棵空的。
* 问的时候文档版本变了、或者停掉了,结果都丢掉。
*/
import type { ExtensionToWebview } from '../editor/messages'
@@ -32,8 +33,8 @@ interface Cached {
export class CallTreeSession {
private generation = 0
private readonly cache = new Map<string, Cached>()
private pending: { version: number, anchor: string } | undefined
private scannedGeneration = -1
/** 这一轮数到的棵数,只记大于 0 的。后台数完、点开时当场问到,发的都是这一份 */
private counts: Record<string, number> = {}
constructor(
private readonly post: (message: ExtensionToWebview) => void,
@@ -44,6 +45,7 @@ export class CallTreeSession {
cancel(): void {
this.generation++
this.cache.clear()
this.counts = {}
}
/** 按眼下这份文档重新数。先停掉上一轮。 */
@@ -52,7 +54,7 @@ export class CallTreeSession {
void this.scan(this.generation)
}
/** 页面要看某一块。不是这一版文档的不理;还没数完就等这一轮数完再发。 */
/** 页面要看某一块。不是这一版文档的不理;收着就直接发;没收着就当场问这一块。 */
open(version: number, anchor: string): void {
const src = this.source()
if (!src || src.version !== version)
@@ -62,9 +64,7 @@ export class CallTreeSession {
this.postTree(version, anchor, cached)
return
}
this.pending = { version, anchor }
if (this.scannedGeneration === this.generation)
this.postEmpty(version, anchor)
void this.askNow(this.generation, src, anchor)
}
dispose(): void {
@@ -76,31 +76,55 @@ export class CallTreeSession {
if (!src || generation !== this.generation)
return
const version = src.version
const counts: Record<string, number> = {}
for (const block of callBlocks(src.blocks)) {
if (generation !== this.generation || this.source()?.version !== version)
if (this.stale(generation, version))
return
const loaded = await src.load(block.defines!.startIndex, () => generation !== this.generation)
if (generation !== this.generation || this.source()?.version !== version)
return
if (!loaded)
continue
const classTarget = block.kind === 'class'
const trees = buildCallTrees(loaded.edges, loaded.symbol, { classTarget })
if (trees.length === 0)
continue
const cached: Cached = { symbol: loaded.symbol, edges: loaded.edges, classTarget }
this.cache.set(block.anchor, cached)
counts[block.anchor] = trees.length
if (this.pending?.anchor === block.anchor && this.pending.version === version)
this.postTree(version, block.anchor, cached)
if (!this.cache.has(block.anchor))
await this.collect(generation, src, block)
}
if (generation !== this.generation || this.source()?.version !== version)
if (this.stale(generation, version))
return
this.scannedGeneration = generation
this.post({ type: 'callTreeCounts', version, counts })
if (this.pending && this.pending.version === version && !this.cache.has(this.pending.anchor))
this.postEmpty(version, this.pending.anchor)
this.postCounts(version)
}
/** 当场问页面点开的那一块:问到了先补发棵数再发树,没问到(找不到这块、没人调用)发一棵空的。 */
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 cached = block && await this.collect(generation, src, block)
if (this.stale(generation, version))
return
if (!cached) {
this.postEmpty(version, anchor)
return
}
this.postCounts(version)
this.postTree(version, anchor, cached)
}
/** 问一块有谁调用。有调用者就收起来、记上棵数再交回来;没人调用、语言不提供、问到一半作废了,都交回空。 */
private async collect(generation: number, src: CallTreeSource, block: CallTreeBlock): Promise<Cached | undefined> {
const version = src.version
const loaded = await src.load(block.defines!.startIndex, () => generation !== this.generation)
if (!loaded || this.stale(generation, version))
return undefined
const classTarget = block.kind === 'class'
const count = buildCallTrees(loaded.edges, loaded.symbol, { classTarget }).length
if (count === 0)
return undefined
const cached: Cached = { symbol: loaded.symbol, edges: loaded.edges, classTarget }
this.cache.set(block.anchor, cached)
this.counts[block.anchor] = count
return cached
}
/** 这一轮停掉了,或者文档版本变了:手上的结果不能再用。 */
private stale(generation: number, version: number): boolean {
return generation !== this.generation || this.source()?.version !== version
}
private postCounts(version: number): void {
this.post({ type: 'callTreeCounts', version, counts: { ...this.counts } })
}
private postTree(version: number, anchor: string, cached: Cached): void {
@@ -66,7 +66,7 @@ describe('块右上角的图标按钮', () => {
expect(buttons[2]).toContain('data-tooltip="编辑"')
})
it('调用树棵数大于 0 时排在编辑后面,按钮上是这个数字;棵数是 0 不出现', () => {
it('调用树排在编辑后面;棵数大于 0 时按钮上带这个数字,是 0 时只有图标', () => {
const html = render(false, { asking: false }, { rewriting: false }, { count: 3 })
const buttons = [...html.matchAll(/<button[^>]*>/g)].map(match => match[0])
expect(buttons).toHaveLength(4)
@@ -74,8 +74,11 @@ describe('块右上角的图标按钮', () => {
expect(buttons[3]).toContain('data-tooltip="调用树" aria-label="调用树"')
expect(buttons[3]).toContain('data-call-tree="fn"')
expect(visibleText(html)).toBe('3')
const hidden = render(false, { asking: false }, { rewriting: false }, { count: 0 })
expect(hidden).not.toContain('调用树')
expect(hidden.match(/<button/g)).toHaveLength(3)
const zero = render(false, { asking: false }, { rewriting: false }, { count: 0 })
const zeroButtons = [...zero.matchAll(/<button[^>]*>/g)].map(match => match[0])
expect(zeroButtons).toHaveLength(4)
expect(zeroButtons[3]).toContain('data-tooltip="调用树" aria-label="调用树"')
expect(visibleText(zero)).toBe('')
expect(zero).not.toContain('cr-call-count')
})
})
@@ -1,5 +1,6 @@
import type { ReactElement } from 'react'
import { Eye, GitBranch, MessageSquareText, Pencil, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
/** 「重写注释」按钮的样子和动作:fresh 是块还没有注释、点了新写一段;正在重写时不能再点。 */
export interface RewriteTool {
@@ -23,7 +24,7 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite
onToggleAsk?: () => void
/** 没给就不显示「按注释规范重写」(不能写注释的块) */
rewrite?: RewriteTool
/** 没给,或棵数是 0,就不显示调用树(还没查到、没有调用者、语言不提供) */
/** 没给就不显示调用树。count 是后台数出的棵数,大于 0 时按钮上带这个数字,是 0 时只有图标(还没数到、没有调用者,点开时会当场再问) */
callTree?: { anchor: string, count: number, open: boolean, onOpen: () => void }
}): ReactElement {
const label = editing ? '预览' : '编辑'
@@ -67,10 +68,10 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite
>
<Icon size={14} strokeWidth={1.8} aria-hidden />
</button>
{callTree && callTree.count > 0 && (
{callTree && (
<button
type="button"
className="cr-tool flex h-5 items-center justify-center gap-px rounded px-1"
className={cn('cr-tool flex h-5 items-center justify-center rounded', callTree.count > 0 ? 'gap-px px-1' : 'w-6')}
data-call-tree={callTree.anchor}
data-active={callTree.open ? '' : undefined}
data-tooltip="调用树"
@@ -79,7 +80,7 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite
onClick={callTree.onOpen}
>
<GitBranch size={14} strokeWidth={1.8} aria-hidden />
<span className="cr-call-count">{callTree.count}</span>
{callTree.count > 0 && <span className="cr-call-count">{callTree.count}</span>}
</button>
)}
</div>
@@ -104,10 +104,10 @@ const MANY: CallTree[] = [
]
describe('调用树弹框', () => {
it('棵数为 0 的函数没有按钮,类有,语句没有;只读的树一排按钮都没有', async () => {
it('函数和类都有按钮,棵数为 0 只有图标、大于 0 带数字,语句没有;只读的树一排按钮都没有', async () => {
const counts = new Map([['function', 0], ['class', 2], ['statement', 4]])
await render(<BlockLevel blocks={[block('function'), block('class'), block('statement')]} tree={tree({ counts, onOpen: () => {} })} />)
expect(container.querySelector('[data-call-tree="function"]')).toBeNull()
expect(container.querySelector('[data-call-tree="function"]')!.textContent).toBe('')
expect(container.querySelector('[data-call-tree="statement"]')).toBeNull()
expect(container.querySelector('[data-call-tree="class"]')).not.toBeNull()
expect(container.querySelector('[data-call-tree="class"]')!.textContent).toBe('2')