🐛 fix(vscode): 切走标签再回来,调用树棵数不用重算,开着的弹框和选中的入口照旧

This commit is contained in:
cruldra committed 2026-10-13 11:00:06 +11:00
1 parent 6cf053dcd0
commit 32018e71f1
9 files changed
+542 -110

No files matched your search

+145 -12
View File
@@ -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))
+109 -60
View File
@@ -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()
}
}