diff --git a/vscode/src/codeReader/callTree/model.test.ts b/vscode/src/codeReader/callTree/model.test.ts new file mode 100644 index 0000000..0821482 --- /dev/null +++ b/vscode/src/codeReader/callTree/model.test.ts @@ -0,0 +1,173 @@ +import type { CallEdge, CallRange, CallSymbol, CallTreeNode } from './model' +import { describe, expect, it } from 'vitest' +import { buildCallTrees } from './model' + +function symbol(name: string, line = 0, extra: Partial = {}): CallSymbol { + return { + uri: `file:///${name}`, + line, + character: 0, + name, + kind: 'function', + path: `${name}.py`, + ...extra, + } +} + +function at(line: number, character = 0): CallRange { + return { line, character, endLine: line, endCharacter: character + 4 } +} + +function edge(caller: CallSymbol, callee: CallSymbol, line: number, character = 0): CallEdge { + return { caller, callee, at: at(line, character) } +} + +function currents(node: CallTreeNode): CallTreeNode[] { + return [...(node.current ? [node] : []), ...node.children.flatMap(currents)] +} + +describe('调用边收成调用树', () => { + const mask = symbol('mask_phone', 27, { path: 'utils/phone.py' }) + + it('两个没有更上层调用者的根是两棵树,每棵记下当前块出现几次', () => { + const read = symbol('读取用户资料', 0, { path: 'routes/user.py' }) + const profile = symbol('get_profile', 10, { path: 'services/user.py' }) + const serialize = symbol('serialize_user', 20, { path: 'services/user.py' }) + const load = symbol('load_user', 30, { path: 'services/user.py' }) + const exp = symbol('导出通讯录', 0, { path: 'routes/export.py' }) + const trees = buildCallTrees([ + edge(serialize, mask, 40), + edge(load, mask, 50), + edge(profile, serialize, 12), + edge(profile, load, 14), + edge(read, profile, 3), + edge(exp, mask, 8), + ], mask) + expect(trees.map(tree => tree.name)).toEqual(['读取用户资料', '导出通讯录']) + expect(trees.map(tree => tree.hits)).toEqual([2, 1]) + expect(trees[0].root.detail).toBe('入口') + expect(trees[0].root.where).toBe('routes/user.py:1') + expect(currents(trees[0].root).map(node => node.where)).toEqual(['utils/phone.py:28', 'utils/phone.py:28']) + expect(currents(trees[1].root)).toHaveLength(1) + }) + + it('同一个调用者的两段不同范围是当前块的两次出现', () => { + const caller = symbol('serialize_user', 20, { path: 'services/user.py' }) + const root = symbol('读取用户资料', 0, { path: 'routes/user.py' }) + const trees = buildCallTrees([ + edge(caller, mask, 10), + edge(caller, mask, 20), + edge(root, caller, 3), + ], mask) + expect(trees).toHaveLength(1) + expect(trees[0].hits).toBe(2) + const parent = trees[0].root.children[0] + expect(parent.name).toBe('serialize_user') + expect(parent.children.map(node => node.current)).toEqual([true, true]) + expect(parent.otherCalls).toBeUndefined() + }) + + it('不在通向当前块的路径上的调用收成一行,不把那些符号画出来', () => { + const caller = symbol('serialize_user', 20, { path: 'services/user.py' }) + const root = symbol('读取用户资料', 0, { path: 'routes/user.py' }) + const trees = buildCallTrees([ + edge(caller, mask, 10), + edge(caller, symbol('format_name'), 11), + edge(caller, symbol('format_name'), 12, 2), + edge(root, caller, 3), + edge(root, symbol('sideways'), 4), + edge(root, symbol('leftover'), 5), + ], mask) + expect(trees).toHaveLength(1) + expect(trees[0].root.otherCalls).toBe(2) + expect(trees[0].root.children).toHaveLength(1) + expect(trees[0].root.children[0].otherCalls).toBe(2) + expect(trees[0].root.children[0].children.map(node => node.name)).toEqual(['mask_phone']) + const text = JSON.stringify(trees) + expect(text).not.toContain('format_name') + expect(text).not.toContain('sideways') + expect(text).not.toContain('leftover') + }) + + it('调用边绕回去就停,不把当前块再画到自己上面', () => { + const caller = symbol('get_profile') + const trees = buildCallTrees([ + edge(caller, mask, 1), + edge(mask, caller, 2), + ], mask) + expect(trees).toHaveLength(1) + expect(trees[0].name).toBe('get_profile') + expect(trees[0].root.children.map(node => node.name)).toEqual(['mask_phone']) + expect(trees[0].root.children[0].current).toBe(true) + expect(trees[0].root.children[0].children).toEqual([]) + expect(trees[0].hits).toBe(1) + }) + + it('往上最多 8 层,再高的调用者不进这棵树', () => { + const above = Array.from({ length: 9 }, (_, index) => symbol(`n${index}`, index)) + const edges = [edge(above[0], mask, 1)] + for (let index = 1; index < above.length; index++) + edges.push(edge(above[index], above[index - 1], index + 1)) + const trees = buildCallTrees(edges, mask) + expect(trees).toHaveLength(1) + expect(trees[0].name).toBe('n7') + expect(JSON.stringify(trees)).not.toContain('n8') + }) + + it('重复的同一段调用范围只算一次', () => { + const caller = symbol('serialize_user') + const trees = buildCallTrees([ + edge(caller, mask, 10), + edge(caller, mask, 10), + ], mask) + expect(trees).toHaveLength(1) + expect(trees[0].hits).toBe(1) + }) + + it('同一次查询里种类变了仍是同一个符号,不拆成两棵', () => { + const asFunction = symbol('get_profile', 4, { uri: 'file:///services/user.py', path: 'services/user.py', kind: 'function' }) + const asClass = symbol('get_profile', 4, { uri: 'file:///services/user.py', path: 'services/user.py', kind: 'class' }) + const trees = buildCallTrees([ + edge(asFunction, mask, 8), + edge(asClass, mask, 8), + ], mask) + expect(trees).toHaveLength(1) + expect(trees[0].hits).toBe(1) + expect(trees[0].root.kind).toBe('function') + }) + + it('没有任何调用者就没有树', () => { + expect(buildCallTrees([], mask)).toEqual([]) + }) + + it('选中的是类时,直接调用它的那一层是「新建」,类自己标当前,不编造方法', () => { + const order = symbol('OrderService', 13, { uri: 'file:///billing/order.py', path: 'billing/order.py', kind: 'class' }) + const create = symbol('create_order', 20, { path: 'billing/order.py', detail: '创建一笔订单' }) + const post = symbol('POST /orders', 0, { path: 'routes/order.py' }) + const trees = buildCallTrees([ + edge(create, order, 39), + edge(post, create, 5), + edge(create, symbol('charge'), 41), + ], order, { classTarget: true }) + expect(trees).toHaveLength(1) + expect(trees[0].hits).toBe(1) + const created = trees[0].root.children[0] + expect(created.name).toBe('create_order') + expect(created.detail).toBe('创建一笔订单') + expect(created.otherCalls).toBe(1) + const made = created.children[0] + expect(made.name).toBe('新建 OrderService') + expect(made.detail).toBe('进入这个类') + expect(made.where).toBe('billing/order.py:40') + expect(made.current).toBe(false) + expect(made.kind).toBe('class') + expect(made.children).toEqual([{ + name: 'OrderService', + kind: 'class', + where: 'billing/order.py:14', + current: true, + children: [], + }]) + expect(JSON.stringify(trees)).not.toContain('方法') + }) +}) diff --git a/vscode/src/codeReader/callTree/model.ts b/vscode/src/codeReader/callTree/model.ts new file mode 100644 index 0000000..cac96e0 --- /dev/null +++ b/vscode/src/codeReader/callTree/model.ts @@ -0,0 +1,215 @@ +/** + * 把调用边收成调用树。边从测试里喂,不碰编辑器。 + * 身份是文件加名字起点;同一次查询里种类会变,种类只用来画图标。 + */ + +/** 往上走到第 8 层就停,这一层当作这棵树的根。 */ +export const CALL_TREE_MAX_DEPTH = 8 + +/** 一个符号:文件、名字起点、给人看的路径和种类。 */ +export interface CallSymbol { + uri: string + /** 名字起点,行和列都从 0 起 */ + line: number + character: number + name: string + /** 块种类,只决定图标,不参与认人 */ + kind: string + /** 从工作区文件夹算起的路径 */ + path: string + detail?: string +} + +/** 调用出现的范围,在调用方文件里,行和列从 0 起。 */ +export interface CallRange { + line: number + character: number + endLine: number + endCharacter: number +} + +/** 一次调用:谁在哪一段范围里调用了谁。同一段范围重复出现只算一次。 */ +export interface CallEdge { + caller: CallSymbol + callee: CallSymbol + at: CallRange +} + +/** 树上的一个节点。当前块可以在同一棵里出现多次,每次一个节点。 */ +export interface CallTreeNode { + name: string + kind: string + /** 第二行。根是「入口」,新建类是「进入这个类」,其余是符号自己的说明(没有就不写) */ + detail?: string + /** 文件:行,行从 1 起 */ + where: string + current: boolean + children: CallTreeNode[] + /** 不在通向当前块的路径上的调用次数;没有就不画那一行 */ + otherCalls?: number +} + +/** 一棵调用树:根是没有更上层调用者的入口。 */ +export interface CallTree { + id: string + /** 入口的名字 */ + name: string + /** 这一棵里当前块出现几次 */ + hits: number + root: CallTreeNode +} + +/** 文件加名字起点。种类不放进来。 */ +export function symbolKey(symbol: Pick): string { + return `${symbol.uri}#${symbol.line}:${symbol.character}` +} + +function rangeKey(at: CallRange): string { + return `${at.line}:${at.character}-${at.endLine}:${at.endCharacter}` +} + +function edgeKey(edge: CallEdge): string { + return `${symbolKey(edge.caller)}>${symbolKey(edge.callee)}@${rangeKey(edge.at)}` +} + +function whereOf(symbol: CallSymbol): string { + return `${symbol.path}:${symbol.line + 1}` +} + +/** + * 从目标往上收到根,再顺着走回目标。 + * 不通向目标的调用不展开,记在父节点的 otherCalls 上。 + * 选中的是类时,直接调用它的那一层写成「新建 类名」。 + */ +export function buildCallTrees(edges: readonly CallEdge[], target: CallSymbol, options?: { classTarget?: boolean }): CallTree[] { + const symbols = new Map() + const remember = (symbol: CallSymbol): CallSymbol => { + const key = symbolKey(symbol) + const known = symbols.get(key) + if (known) + return known + symbols.set(key, symbol) + return symbol + } + const targetSymbol = remember(target) + const targetKey = symbolKey(targetSymbol) + const unique: CallEdge[] = [] + const seen = new Set() + for (const edge of edges) { + const normalized: CallEdge = { caller: remember(edge.caller), callee: remember(edge.callee), at: edge.at } + const key = edgeKey(normalized) + if (seen.has(key)) + continue + seen.add(key) + unique.push(normalized) + } + + const byCallee = new Map() + const byCaller = new Map() + for (const edge of unique) { + pushTo(byCallee, symbolKey(edge.callee), edge) + pushTo(byCaller, symbolKey(edge.caller), edge) + } + if ((byCallee.get(targetKey) ?? []).length === 0) + return [] + + const depthOf = new Map() + const roots: CallSymbol[] = [] + const rootSeen = new Set() + const addRoot = (symbol: CallSymbol) => { + const key = symbolKey(symbol) + if (rootSeen.has(key)) + return + rootSeen.add(key) + roots.push(symbol) + } + const climb = (node: CallSymbol, depth: number, path: ReadonlySet) => { + const key = symbolKey(node) + const known = depthOf.get(key) + if (known !== undefined && known <= depth) + return + depthOf.set(key, depth) + if (depth >= CALL_TREE_MAX_DEPTH) { + addRoot(node) + return + } + const callers = new Map() + for (const edge of byCallee.get(key) ?? []) { + const callerKey = symbolKey(edge.caller) + if (!path.has(callerKey) && !callers.has(callerKey)) + callers.set(callerKey, edge.caller) + } + if (callers.size === 0) { + addRoot(node) + return + } + const nextPath = new Set(path) + nextPath.add(key) + for (const caller of callers.values()) + climb(caller, depth + 1, nextPath) + } + climb(targetSymbol, 0, new Set()) + + const classTarget = options?.classTarget === true + const render = (node: CallSymbol, path: ReadonlySet): CallTreeNode => { + const key = symbolKey(node) + const current = key === targetKey + const nodeDepth = depthOf.get(key) ?? 0 + const children: CallTreeNode[] = [] + let otherCalls = 0 + if (!current) { + const nextPath = new Set(path) + nextPath.add(key) + for (const edge of byCaller.get(key) ?? []) { + const calleeKey = symbolKey(edge.callee) + if (nextPath.has(calleeKey)) + continue + const calleeDepth = depthOf.get(calleeKey) + if (calleeDepth === undefined) { + otherCalls++ + continue + } + if (calleeDepth >= nodeDepth) + continue + const child = classTarget && calleeKey === targetKey + ? { + name: `新建 ${targetSymbol.name}`, + kind: 'class', + detail: '进入这个类', + where: `${node.path}:${edge.at.line + 1}`, + current: false, + children: [render(edge.callee, nextPath)], + } + : render(edge.callee, nextPath) + children.push(child) + } + } + return { + name: node.name, + kind: node.kind, + ...(node.detail && { detail: node.detail }), + where: whereOf(node), + current, + children, + ...(otherCalls > 0 && { otherCalls }), + } + } + + return roots.map((root) => { + const rendered = render(root, new Set()) + rendered.detail = '入口' + return { id: symbolKey(root), name: root.name, hits: countCurrent(rendered), root: rendered } + }) +} + +function pushTo(map: Map, key: string, edge: CallEdge): void { + const list = map.get(key) + if (list) + list.push(edge) + else + map.set(key, [edge]) +} + +function countCurrent(node: CallTreeNode): number { + return (node.current ? 1 : 0) + node.children.reduce((sum, child) => sum + countCurrent(child), 0) +} diff --git a/vscode/src/codeReader/callTree/query.ts b/vscode/src/codeReader/callTree/query.ts new file mode 100644 index 0000000..7529656 --- /dev/null +++ b/vscode/src/codeReader/callTree/query.ts @@ -0,0 +1,141 @@ +/** + * 问编辑器的调用关系命令,收成调用边。 + * 语言扩展不提供、或者这一轮已经作废,就当没有。 + */ + +import type { CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall, TextDocument } from 'vscode' +import type { CallEdge, CallRange, CallSymbol } from './model' +import { commands, SymbolKind, workspace } from 'vscode' +import { symbolKey } from './model' +import { collectCallEdges } from './walk' + +/** + * 在 offset 这个名字起点上问调用关系。 + * 先准备符号,再往上问谁调用了它;路径上的调用者再问它还调用了谁,用来收「另外 N 处」。 + */ +export async function loadCallEdges( + document: TextDocument, + offset: number, + cancelled: () => boolean, +): Promise<{ symbol: CallSymbol, edges: CallEdge[] } | undefined> { + if (cancelled()) + return undefined + const position = document.positionAt(offset) + let prepared: CallHierarchyItem | CallHierarchyItem[] | null | undefined + try { + prepared = await commands.executeCommand('vscode.prepareCallHierarchy', document.uri, position) + } + catch { + return undefined + } + if (cancelled() || prepared == null) + return undefined + const items = (Array.isArray(prepared) ? prepared : [prepared]).filter(item => item) + if (items.length === 0) + return undefined + + const vscodeItems = new Map() + const remember = (item: CallHierarchyItem): CallSymbol => { + const symbol = toSymbol(item) + vscodeItems.set(symbolKey(symbol), item) + return symbol + } + const starts: CallSymbol[] = [] + const seen = new Set() + for (const item of items) { + const symbol = remember(item) + const key = symbolKey(symbol) + if (seen.has(key)) + continue + seen.add(key) + starts.push(symbol) + } + const symbol = starts.find(item => item.line === position.line && item.character === position.character) ?? starts[0]! + const edges: CallEdge[] = [] + for (const start of starts) { + if (cancelled()) + return undefined + edges.push(...await collectCallEdges(start, incoming, outgoing, cancelled)) + } + return cancelled() ? undefined : { symbol, edges } + + async function incoming(node: CallSymbol) { + return hits(node, 'vscode.provideIncomingCalls', (call: CallHierarchyIncomingCall) => ({ + from: remember(call.from), + ranges: call.fromRanges.map(toRange), + })) + } + + async function outgoing(node: CallSymbol) { + return hits(node, 'vscode.provideOutgoingCalls', (call: CallHierarchyOutgoingCall) => ({ + to: remember(call.to), + ranges: call.fromRanges.map(toRange), + })) + } + + async function hits(node: CallSymbol, command: string, map: (call: T) => R): Promise { + const item = vscodeItems.get(symbolKey(node)) + if (!item || cancelled()) + return [] + try { + const calls = await commands.executeCommand(command, item) + return (calls ?? []).map(map) + } + catch { + return [] + } + } +} + +function toSymbol(item: CallHierarchyItem): CallSymbol { + const start = item.selectionRange.start + return { + uri: item.uri.toString(), + line: start.line, + character: start.character, + name: item.name, + kind: kindName(item.kind), + path: workspace.asRelativePath(item.uri), + ...(item.detail && { detail: item.detail }), + } +} + +function toRange(range: CallHierarchyItem['range']): CallRange { + return { + line: range.start.line, + character: range.start.character, + endLine: range.end.line, + endCharacter: range.end.character, + } +} + +/** 编辑器的符号种类换成块种类,只给图标用。 */ +function kindName(kind: SymbolKind): string { + switch (kind) { + case SymbolKind.Method: + case SymbolKind.Function: + case SymbolKind.Operator: + return 'function' + case SymbolKind.Class: + case SymbolKind.Struct: + case SymbolKind.Constructor: + return 'class' + case SymbolKind.Interface: + case SymbolKind.TypeParameter: + return 'type' + case SymbolKind.Enum: + return 'enum' + case SymbolKind.Field: + return 'field' + case SymbolKind.Variable: + return 'variable' + case SymbolKind.Constant: + return 'const' + case SymbolKind.Module: + case SymbolKind.Namespace: + case SymbolKind.Package: + return 'module' + default: + return 'function' + } +} diff --git a/vscode/src/codeReader/callTree/session.test.ts b/vscode/src/codeReader/callTree/session.test.ts new file mode 100644 index 0000000..55e6e75 --- /dev/null +++ b/vscode/src/codeReader/callTree/session.test.ts @@ -0,0 +1,108 @@ +import type { CallEdge, CallSymbol } from './model' +import type { CallTreeBlock, CallTreeSource } from './session' +import { describe, expect, it } from 'vitest' +import { CallTreeSession } from './session' + +function symbol(name: string): CallSymbol { + return { uri: `file:///${name}`, line: 0, character: 0, name, kind: 'function', path: `${name}.py` } +} + +function block(anchor: string, kind: string, defines?: number, children: CallTreeBlock[] = []): CallTreeBlock { + return { anchor, kind, ...(defines !== undefined && { defines: { startIndex: defines } }), children } +} + +describe('调用树棵数在文档发出去之后算', () => { + it('只给有名字起点的函数和类计数,没有调用者的不出现,嵌在里面的也算', async () => { + const mask = symbol('mask_phone') + const caller = symbol('get_profile') + const edges: CallEdge[] = [{ + caller, + callee: mask, + at: { line: 1, character: 0, endLine: 1, endCharacter: 4 }, + }] + let source: CallTreeSource | undefined = { + version: 3, + blocks: [ + block('fn', 'function', 10), + block('no-name', 'function'), + block('stmt', 'statement', 4), + block('cls', 'class', 20, [block('inner', 'function', 30)]), + block('alone', 'function', 40), + ], + load: async (offset) => { + if (offset === 10) + return { symbol: mask, edges } + if (offset === 30) + return { symbol: symbol('inner'), edges: [{ caller, callee: symbol('inner'), at: edges[0].at }] } + return { symbol: symbol('nobody'), edges: [] } + }, + } + const posted: { type: string, counts?: Record }[] = [] + const session = new CallTreeSession(message => posted.push(message), () => source) + session.refresh() + await viFlush() + expect(posted).toEqual([{ type: 'callTreeCounts', version: 3, counts: { fn: 1, inner: 1 } }]) + source = undefined + session.dispose() + }) + + it('文档版本变了就丢掉还在跑的查询,不把旧结果发出去', async () => { + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + let version = 1 + const source: CallTreeSource = { + get version() { + return version + }, + blocks: [block('fn', 'function', 1)], + load: async () => { + await gate + return { symbol: symbol('mask_phone'), edges: [] } + }, + } + const posted: unknown[] = [] + const session = new CallTreeSession(message => posted.push(message), () => source) + session.refresh() + version = 2 + session.refresh() + release!() + await viFlush() + expect(posted).toEqual([{ type: 'callTreeCounts', version: 2, counts: {} }]) + 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 } } + const source: CallTreeSource = { + version: 4, + blocks: [block('fn', 'function', 1)], + load: async () => ({ symbol: mask, edges: [edge] }), + } + const posted: { type: string, version?: number, anchor?: string, trees?: { name: string }[] }[] = [] + const session = new CallTreeSession(message => posted.push(message), () => source) + session.open(3, 'fn') + expect(posted).toEqual([]) + session.refresh() + await viFlush() + posted.length = 0 + session.open(4, 'fn') + expect(posted).toEqual([{ + type: 'callTree', + version: 4, + anchor: 'fn', + name: 'mask_phone', + where: 'mask_phone.py:1', + trees: [expect.objectContaining({ name: 'get_profile', hits: 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 new file mode 100644 index 0000000..6eca451 --- /dev/null +++ b/vscode/src/codeReader/callTree/session.ts @@ -0,0 +1,136 @@ +/** + * 文档发出去之后,在后台数每个函数、类落在几棵调用树里。 + * 页面点开某一块时,把这一版已经收好的边收成树发回去。版本对不上就丢掉。 + */ + +import type { ExtensionToWebview } from '../editor/messages' +import type { CallEdge, CallSymbol, CallTree } from './model' +import { buildCallTrees } from './model' + +/** 页面上的一块里,数调用树用得到的那几项。 */ +export interface CallTreeBlock { + anchor: string + kind: string + defines?: { startIndex: number } + children?: readonly CallTreeBlock[] +} + +/** 眼下这份文档,以及按名字起点去问调用关系。问的时候可以取消。 */ +export interface CallTreeSource { + version: number + blocks: readonly CallTreeBlock[] + load: (offset: number, cancelled: () => boolean) => Promise<{ symbol: CallSymbol, edges: CallEdge[] } | undefined> +} + +interface Cached { + symbol: CallSymbol + edges: CallEdge[] + classTarget: boolean +} + +/** 一份文档的调用树:旧的查询作废,不挡住第一次预览。 */ +export class CallTreeSession { + private generation = 0 + private readonly cache = new Map() + private pending: { version: number, anchor: string } | undefined + private scannedGeneration = -1 + + constructor( + private readonly post: (message: ExtensionToWebview) => void, + private readonly source: () => CallTreeSource | undefined, + ) {} + + /** 停掉还在跑的查询。文档又改了、预览关掉时用。 */ + cancel(): void { + this.generation++ + this.cache.clear() + } + + /** 按眼下这份文档重新数。先停掉上一轮。 */ + 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) + return + } + this.pending = { version, anchor } + if (this.scannedGeneration === this.generation) + this.postEmpty(version, anchor) + } + + dispose(): void { + this.cancel() + } + + private async scan(generation: number): Promise { + const src = this.source() + if (!src || generation !== this.generation) + return + const version = src.version + const counts: Record = {} + for (const block of callBlocks(src.blocks)) { + if (generation !== this.generation || this.source()?.version !== 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 (generation !== this.generation || this.source()?.version !== 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) + } + + private postTree(version: number, anchor: string, cached: Cached): void { + const trees: CallTree[] = buildCallTrees(cached.edges, cached.symbol, { classTarget: cached.classTarget }) + this.post({ + type: 'callTree', + version, + anchor, + name: cached.symbol.name, + where: `${cached.symbol.path}:${cached.symbol.line + 1}`, + trees, + }) + } + + private postEmpty(version: number, anchor: string): void { + this.post({ type: 'callTree', version, anchor, name: '', where: '', trees: [] }) + } +} + +/** 有名字起点的函数和类,含嵌在别的块里面的。 */ +function callBlocks(blocks: readonly CallTreeBlock[]): CallTreeBlock[] { + const found: CallTreeBlock[] = [] + const walk = (list: readonly CallTreeBlock[]) => { + for (const block of list) { + if ((block.kind === 'function' || block.kind === 'class') && block.defines) + found.push(block) + if (block.children?.length) + walk(block.children) + } + } + walk(blocks) + return found +} diff --git a/vscode/src/codeReader/callTree/walk.test.ts b/vscode/src/codeReader/callTree/walk.test.ts new file mode 100644 index 0000000..e784ae5 --- /dev/null +++ b/vscode/src/codeReader/callTree/walk.test.ts @@ -0,0 +1,101 @@ +import type { CallRange, CallSymbol } from './model' +import { describe, expect, it } from 'vitest' +import { collectCallEdges } from './walk' + +function symbol(name: string, kind = 'function'): CallSymbol { + return { uri: `file:///${name}`, line: 0, character: 0, name, kind, path: `${name}.py` } +} + +function range(line: number, character = 0): CallRange { + return { line, character, endLine: line, endCharacter: character + 3 } +} + +describe('顺着调用者往上收集调用边', () => { + it('有环就停,不再问已经走过的那个', async () => { + const target = symbol('mask_phone') + const caller = symbol('get_profile') + const asked: string[] = [] + const edges = await collectCallEdges(target, async (node) => { + asked.push(node.name) + if (node.name === 'mask_phone') + return [{ from: caller, ranges: [range(1)] }] + return [{ from: target, ranges: [range(2)] }] + }, async () => []) + expect(asked).toEqual(['mask_phone', 'get_profile']) + expect(edges).toEqual([{ caller, callee: target, at: range(1) }]) + }) + + it('往上最多问 8 层,第 9 层的调用者不再问', async () => { + const names = ['t', ...Array.from({ length: 9 }, (_, index) => `n${index}`)] + const asked: string[] = [] + await collectCallEdges(symbol('t'), async (node) => { + asked.push(node.name) + const index = names.indexOf(node.name) + const next = names[index + 1] + return next ? [{ from: symbol(next), ranges: [range(index + 1)] }] : [] + }, async () => []) + expect(asked).toEqual(['t', 'n0', 'n1', 'n2', 'n3', 'n4', 'n5', 'n6']) + }) + + it('重复的调用范围去掉,同一调用者的两段不同范围都留下', async () => { + const target = symbol('mask_phone') + const caller = symbol('serialize_user') + const edges = await collectCallEdges(target, async () => [{ + from: caller, + ranges: [range(10), range(10), range(20)], + }], async () => []) + expect(edges.map(item => item.at.line)).toEqual([10, 20]) + }) + + it('种类不同但名字起点相同的调用者是同一个人,只往上问一次', async () => { + const target = symbol('mask_phone') + const asFunction = symbol('get_profile', 'function') + const asClass = symbol('get_profile', 'class') + const asked: string[] = [] + const edges = await collectCallEdges(target, async (node) => { + asked.push(`${node.name}:${node.kind}`) + if (node.name === 'mask_phone') + return [{ from: asFunction, ranges: [range(1)] }, { from: asClass, ranges: [range(1)] }] + return [] + }, async () => []) + expect(asked.filter(item => item.startsWith('get_profile'))).toEqual(['get_profile:function']) + expect(edges).toHaveLength(1) + }) + + it('路径上的调用者还要问它调用了谁,不通向当前块的边也留下;当前块自己不再往下问', async () => { + const target = symbol('mask_phone') + const caller = symbol('serialize_user') + const side = symbol('format_name') + const outgoingAsked: string[] = [] + const edges = await collectCallEdges(target, async (node) => { + if (node.name === 'mask_phone') + return [{ from: caller, ranges: [range(4)] }] + return [] + }, async (node) => { + outgoingAsked.push(node.name) + return [ + { to: target, ranges: [range(4)] }, + { to: side, ranges: [range(8), range(8)] }, + ] + }) + expect(outgoingAsked).toEqual(['serialize_user']) + expect(edges).toEqual([ + { caller, callee: target, at: range(4) }, + { caller, callee: side, at: range(8) }, + ]) + }) + + it('取消之后不再问下一个人', async () => { + const target = symbol('mask_phone') + const caller = symbol('get_profile') + let stop = false + const asked: string[] = [] + const edges = await collectCallEdges(target, async (node) => { + asked.push(node.name) + stop = true + return [{ from: caller, ranges: [range(1)] }] + }, async () => [], () => stop) + expect(asked).toEqual(['mask_phone']) + expect(edges).toEqual([]) + }) +}) diff --git a/vscode/src/codeReader/callTree/walk.ts b/vscode/src/codeReader/callTree/walk.ts new file mode 100644 index 0000000..9a015fe --- /dev/null +++ b/vscode/src/codeReader/callTree/walk.ts @@ -0,0 +1,108 @@ +/** + * 从当前符号往上问调用者,收到没有更上层的入口为止。 + * 问的动作由外面传入:测试喂假的,宿主去问编辑器命令。 + */ + +import type { CallEdge, CallRange, CallSymbol } from './model' +import { CALL_TREE_MAX_DEPTH, symbolKey } from './model' + +/** 一个调用者,以及它里面每一处调用的范围。 */ +export interface IncomingHit { + from: CallSymbol + ranges: readonly CallRange[] +} + +/** 一个被调用的符号,以及调用它的每一处范围。 */ +export interface OutgoingHit { + to: CallSymbol + ranges: readonly CallRange[] +} + +/** + * 收集通向 start 的调用边,再加上这些调用者通向别处的边。 + * 有环就停。往上最多 8 层。cancelled 变成真就停,已经收到的也不要了。 + * start 自己不再往下问:它调用了谁不画在这棵树上。 + */ +export async function collectCallEdges( + start: CallSymbol, + incoming: (node: CallSymbol) => Promise, + outgoing: (node: CallSymbol) => Promise, + cancelled: () => boolean = () => false, +): Promise { + if (cancelled()) + return [] + const edges: CallEdge[] = [] + const seen = new Set() + const ancestors = new Map() + const walked = new Map() + const startKey = symbolKey(start) + + const add = (caller: CallSymbol, callee: CallSymbol, at: CallRange) => { + const edge: CallEdge = { caller, callee, at } + const key = `${symbolKey(caller)}>${symbolKey(callee)}@${at.line}:${at.character}-${at.endLine}:${at.endCharacter}` + if (seen.has(key)) + return + seen.add(key) + edges.push(edge) + } + + const walk = async (node: CallSymbol, depth: number, path: ReadonlySet): Promise => { + if (cancelled() || depth >= CALL_TREE_MAX_DEPTH) + return + const calls = await incoming(node) ?? [] + if (cancelled()) + return + const here = new Set(path) + here.add(symbolKey(node)) + const next = new Map() + for (const call of calls) { + const key = symbolKey(call.from) + if (here.has(key)) + continue + for (const at of uniqueRanges(call.ranges)) + add(call.from, node, at) + if (key !== startKey && !ancestors.has(key)) + ancestors.set(key, call.from) + if (!next.has(key)) + next.set(key, call.from) + } + for (const [key, caller] of next) { + if (cancelled() || depth + 1 >= CALL_TREE_MAX_DEPTH) + continue + const known = walked.get(key) + if (known !== undefined && known <= depth + 1) + continue + walked.set(key, depth + 1) + await walk(caller, depth + 1, here) + } + } + + await walk(start, 0, new Set()) + if (cancelled()) + return [] + for (const ancestor of ancestors.values()) { + if (cancelled()) + return [] + const calls = await outgoing(ancestor) ?? [] + if (cancelled()) + return [] + for (const call of calls) { + for (const at of uniqueRanges(call.ranges)) + add(ancestor, call.to, at) + } + } + return cancelled() ? [] : edges +} + +function uniqueRanges(ranges: readonly CallRange[]): CallRange[] { + const seen = new Set() + const unique: CallRange[] = [] + for (const range of ranges) { + const key = `${range.line}:${range.character}-${range.endLine}:${range.endCharacter}` + if (seen.has(key)) + continue + seen.add(key) + unique.push(range) + } + return unique +} diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 9816cbf..48a2afb 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -5,6 +5,7 @@ import type { ClaudeImage } from '../../cc/spawnClaude' import type { FileAttribute } from '../attributes' import type { BlockView } from '../blockView' +import type { CallTree } from '../callTree/model' import type { DocumentDiff } from '../diff/previewDiff' import type { ShownName } from '../nameResolution' import type { ProfileChoice } from '../qa/askProfiles' @@ -129,6 +130,8 @@ export interface StickyScrollSettings { * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到 * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了 * repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在 + * callTreeCounts:version 这份文档里,每个函数、类落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理 + * callTree:anchor 那块的调用树。trees 空着是没有调用它的地方。不是页面上这个版本的不理 */ export type ExtensionToWebview = | DocumentMessage @@ -153,6 +156,8 @@ export type ExtensionToWebview | { type: 'repoLinkOpened', path?: string, symbol?: string } | { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] } | { type: 'focusPathAsk' } + | { type: 'callTreeCounts', version: number, counts: Record } + | { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[] } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) @@ -178,6 +183,7 @@ export type ExtensionToWebview * 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),按这份文件找 symbol(本文件定义的,再是导入的,见 symbolLinks.ts) * checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(symbols)问哪些找不到。id 标出是哪一次问 * checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问 + * openCallTree:打开 anchor 那块的调用树。version 是这块所在的文档版本,对不上就不查 */ export type WebviewToExtension = | { type: 'ready' } @@ -201,3 +207,4 @@ export type WebviewToExtension | { type: 'openRepoLink', path?: string, symbol?: string } | { type: 'checkRepoLinks', id: number, paths: string[], symbols: string[] } | { type: 'checkRepoImages', id: number, paths: string[] } + | { type: 'openCallTree', version: number, anchor: string } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 98ce6ff..bb0624a 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -23,6 +23,8 @@ import { commands, EndOfLine, Position, Range, Uri, window, workspace, Workspace import { logger } from '../../logging/logger' import { webviewHtml, webviewUiRoot } from '../../webview/html' import { DocumentCallees } from '../calleeKinds' +import { loadCallEdges } from '../callTree/query' +import { CallTreeSession } from '../callTree/session' import { findBlock, rewriteBlockComment } from '../commentRewrite' import { PreviewDiff } from '../diff/previewDiff' import { blockReplacement } from '../editText' @@ -187,6 +189,20 @@ class Preview { private readonly rewriteStops = new Map() /** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */ private sent: SentDocument | undefined + /** 函数、类落在几棵调用树里。文档发出去之后再数,不挡住第一次预览 */ + private readonly callTrees = new CallTreeSession( + message => this.post(message), + () => { + if (!this.sent || this.sent.version !== this.document.version) + return undefined + return { + version: this.sent.version, + blocks: this.sent.blocks, + load: (offset, cancelled) => loadCallEdges(this.document, offset, cancelled), + } + }, + ) + /** 最近一次发出去的文档里语言插件自己认得出的名字(LanguageAdapter.symbols):语言扩展查不到定义、各处出现时用 */ private symbols: { version: number, list: LocalSymbol[] } | undefined /** 最近一次发出去的文档里各条导入引进来的名字:注释里不带路径的引用按它找导入的定义 */ @@ -318,6 +334,7 @@ class Preview { if (!this.ready) return const request = ++this.latest + this.callTrees.cancel() if (pathPreviewOf(this.document.uri)) { this.sent = undefined const reveal = this.pendingReveal @@ -359,6 +376,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() } } @@ -455,6 +473,7 @@ class Preview { } dispose(): void { + this.callTrees.dispose() this.rewriteAbort.abort() clearTimeout(this.timer) this.names.dispose() @@ -608,6 +627,9 @@ class Preview { case 'checkRepoImages': await this.checkRepoImages(msg.id, msg.paths) return + case 'openCallTree': + this.callTrees.open(msg.version, msg.anchor) + return case 'occurrenceNavigation': this.owner.navigation.update(this, msg.available) } diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 8413216..94be0f4 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -1,13 +1,14 @@ import type { ReactElement } from 'react' import type { BlockTreeState } from './BlockTree' import type { NameMap, VersionNames } from './displayNames' -import type { DocumentDiff, DocumentMessage, Excluded, Reveal } from './types' +import type { BlockView, CallTree, DocumentDiff, DocumentMessage, Excluded, Reveal } from './types' import type { OccurrencePage } from './useOccurrences' import type { ViewState } from './viewState' import { TriangleAlert } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AskThread } from './AskThread' import { BlockLevel } from './BlockTree' +import { CallTreeDialog } from './CallTreeDialog' import { DefinitionPeekPopup } from './DefinitionPeekPopup' import { DiffBar, NewFileBanner } from './DiffBar' import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView' @@ -42,6 +43,17 @@ function savedBlockModes(): BlockModes { return { editing, folds } } +function blockKind(blocks: readonly BlockView[], anchor: string): string { + for (const block of blocks) { + if (block.anchor === anchor) + return block.kind + const inner = blockKind(block.children, anchor) + if (inner) + return inner + } + return '' +} + export function App(): ReactElement | null { // 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答 const qa = useQa() @@ -64,6 +76,18 @@ export function App(): ReactElement | null { const [diff, setDiff] = useState(null) /** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */ const editing = useRef(view.editing) + /** 页面上这份文档的版本:调用树的回答不是这一版就丢掉 */ + 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 + trees: CallTree[] + } | null>(null) /** 块树所在的页面内容:钉住卡片时在这里面量 */ const content = useRef(null) // 解析失败的文档没有块,不标差异;删掉的块按块筛选同样藏 @@ -113,12 +137,29 @@ export function App(): ReactElement | null { const off = onMessage((msg) => { switch (msg.type) { case 'document': + 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) if (msg.reveal) setReveal(msg.reveal) return + case 'callTreeCounts': + if (msg.version !== docVersion.current) + return + setCallCounts({ version: msg.version, counts: msg.counts }) + return + case 'callTree': + if (msg.version !== docVersion.current) + return + setOpenCall((prev) => { + if (!prev || prev.anchor !== msg.anchor || prev.version !== msg.version) + return prev + return { ...prev, status: 'ready', name: msg.name, where: msg.where, trees: msg.trees } + }) + return case 'reveal': setReveal({ line: msg.line, definition: msg.definition }) return @@ -148,6 +189,12 @@ export function App(): ReactElement | null { }, [view]) const onFocused = useCallback(() => setFocusId(null), []) + const onOpenCall = useCallback((anchor: string) => { + if (!doc) + return + setOpenCall({ anchor, version: doc.version, status: 'loading', name: '', where: '', kind: blockKind(doc.blocks, anchor) || 'function', trees: [] }) + postMessage({ type: 'openCallTree', version: doc.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]) const tree = useMemo((): BlockTreeState | null => doc && { @@ -178,7 +225,12 @@ export function App(): ReactElement | null { ...(changes && { diff: changes }), ...(folded && { folded }), onOpenRun: id => setOpenedRuns(prev => new Set([...prev, id])), - }, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewrites, qa, changes, folded]) + callTree: { + counts: callCounts?.version === doc.version ? new Map(Object.entries(callCounts.counts)) : new Map(), + ...(openCall?.version === doc.version && { openAnchor: openCall.anchor }), + onOpen: onOpenCall, + }, + }, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewrites, qa, changes, folded, callCounts, openCall, onOpenCall]) if (!doc || !tree) return null @@ -248,6 +300,16 @@ export function App(): ReactElement | null { {sticky?.enabled && } + {openCall && ( + setOpenCall(null)} + /> + )} ) diff --git a/vscode/webview-ui/src/codeReader/BlockTools.test.tsx b/vscode/webview-ui/src/codeReader/BlockTools.test.tsx index fa9c5c2..c1e7af6 100644 --- a/vscode/webview-ui/src/codeReader/BlockTools.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTools.test.tsx @@ -2,13 +2,14 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' import { BlockTools } from './BlockTools' -function render(editing: boolean, ask?: { asking: boolean }, rewrite?: { rewriting: boolean, fresh?: boolean }): string { +function render(editing: boolean, ask?: { asking: boolean }, rewrite?: { rewriting: boolean, fresh?: boolean }, callTree?: { count: number }): string { return renderToStaticMarkup( {}} {...(ask && { asking: ask.asking, onToggleAsk: () => {} })} {...(rewrite && { rewrite: { rewriting: rewrite.rewriting, fresh: rewrite.fresh ?? false, onRewrite: () => {} } })} + {...(callTree && { callTree: { anchor: 'fn', count: callTree.count, open: false, onOpen: () => {} } })} />, ) } @@ -64,4 +65,17 @@ describe('块右上角的图标按钮', () => { expect(buttons[1]).toContain('data-tooltip="问 AI"') expect(buttons[2]).toContain('data-tooltip="编辑"') }) + + it('调用树棵数大于 0 时排在编辑后面,按钮上是这个数字;棵数是 0 不出现', () => { + const html = render(false, { asking: false }, { rewriting: false }, { count: 3 }) + const buttons = [...html.matchAll(/]*>/g)].map(match => match[0]) + expect(buttons).toHaveLength(4) + expect(buttons[2]).toContain('data-tooltip="编辑"') + 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(/ + {callTree && callTree.count > 0 && ( + + )} ) } diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 1eaed89..1a4a66b 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -37,9 +37,18 @@ export interface EditTree { onStopRewrite: (anchor: string) => void } +/** 函数、类落在几棵调用树里。定义浮窗不传,跟问 AI 一样不画这排按钮。 */ +export interface CallTreeTools { + counts: ReadonlyMap + openAnchor?: string + onOpen: (anchor: string) => void +} + export interface BlockTreeState { /** 能编辑(页面上的块树);定义卡片浮窗里没有 */ edit?: EditTree + /** 调用树。只读的树不传 */ + callTree?: CallTreeTools /** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */ collapsed: ReadonlySet /** 按筛选不画的块(连同子孙),见 applyFilter */ @@ -227,6 +236,18 @@ function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTre } /** 这块的「重写注释」按钮,排在右上角那排按钮里:能写注释(commentTarget)、页面上能改代码时才有。 */ +function callTreeTool(block: BlockView, tree: BlockTreeState) { + const callTree = tree.callTree + if (!callTree || (block.kind !== 'function' && block.kind !== 'class')) + return undefined + return { + anchor: block.anchor, + count: callTree.counts.get(block.anchor) ?? 0, + open: callTree.openAnchor === block.anchor, + onOpen: () => callTree.onOpen(block.anchor), + } +} + function rewriteTool(block: BlockView, edit: EditTree | undefined): RewriteTool | undefined { if (!edit || !block.commentTarget) return undefined @@ -319,6 +340,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): asking={tree.qa?.state.open.includes(block.anchor)} onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))} rewrite={rewriteTool(block, edit)} + callTree={callTreeTool(block, tree)} /> )} diff --git a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx new file mode 100644 index 0000000..2fe2da2 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx @@ -0,0 +1,225 @@ +// @vitest-environment happy-dom + +import type { ReactElement } from 'react' +import type { Root } from 'react-dom/client' +import type { BlockTreeState } from './BlockTree' +import type { BlockView, CallTree } from './types' +import { act, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { BlockLevel } from './BlockTree' +import { CallTreeDialog } from './CallTreeDialog' + +vi.mock('./useOccurrences', async () => { + const { createContext } = await import('react') + return { HighlightContext: createContext(undefined) } +}) +vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } })) + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +async function render(element: ReactElement): Promise { + await act(async () => root.render(element)) +} + +function block(kind: string, id = kind): BlockView { + return { + id, + anchor: id, + kind, + startLine: 0, + endLine: 1, + code: '', + codeTokens: [], + attributes: { kind }, + concepts: [{ id: kind, label: kind, icon: kind === 'class' ? 'box' : 'braces' }], + children: [], + translation: [{ text: id, role: 'plain' }], + } +} + +function tree(callTree?: BlockTreeState['callTree'], edit = true): BlockTreeState { + return { + ...(edit && { + edit: { + version: 1, + editing: new Set(), + focusId: null, + onFocused: () => {}, + onToggleEdit: () => {}, + rewrites: new Map(), + onRewriteComment: () => {}, + onStopRewrite: () => {}, + }, + }), + collapsed: new Set(), + hidden: new Set(), + temporary: new Set(), + covered: new Map(), + kindName: kind => kind, + onToggleFold: () => {}, + onSetFolds: () => {}, + onOpenRun: () => {}, + ...(callTree && { callTree }), + } +} + +function node(name: string, extra: Partial = {}): CallTree['root'] { + return { name, kind: 'function', where: `${name}.py:1`, current: false, children: [], ...extra } +} + +const ONE: CallTree = { + id: 'one', + name: '夜间对账', + hits: 1, + root: node('夜间对账', { detail: '入口', children: [node('mask_phone', { current: true, where: 'utils/phone.py:28' })] }), +} + +const MANY: CallTree[] = [ + { + id: 'read', + name: '读取用户资料', + hits: 2, + root: node('读取用户资料', { + detail: '入口', + otherCalls: 2, + children: [node('mask_phone', { current: true }), node('mask_phone', { current: true })], + }), + }, + ONE, +] + +describe('调用树弹框', () => { + it('棵数为 0 的函数没有按钮,类有,语句没有;只读的树一排按钮都没有', async () => { + const counts = new Map([['function', 0], ['class', 2], ['statement', 4]]) + await render( {} })} />) + expect(container.querySelector('[data-call-tree="function"]')).toBeNull() + 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') + + await render( {} }, false)} />) + expect(container.querySelector('[aria-label="块操作"]')).toBeNull() + expect(container.querySelector('[data-call-tree="function"]')).toBeNull() + }) + + it('弹框打开时原来的卡片还在,关掉之后弹框没了,焦点回到那个按钮', async () => { + const card = block('function', 'mask') + function Page(): ReactElement { + const [open, setOpen] = useState(false) + return ( + <> + setOpen(true) })} + /> + {open && ( + setOpen(false)} + /> + )} + + ) + } + await render() + const button = container.querySelector('[data-call-tree="mask"]')! + await act(async () => button.click()) + expect(container.querySelector('[data-id="mask"]')).not.toBeNull() + const dialog = document.body.querySelector('[role="dialog"]') + expect(dialog).not.toBeNull() + expect(dialog!.textContent).toContain('调用树') + expect(dialog!.textContent).toContain('mask_phone') + expect(dialog!.textContent).toContain('utils/phone.py:28') + expect(dialog!.textContent).not.toContain('读取用户资料') + expect(dialog!.querySelector('[role="tablist"]')).toBeNull() + + await act(async () => dialog!.querySelector('[aria-label="关闭"]')!.click()) + expect(document.body.querySelector('[role="dialog"]')).toBeNull() + expect(container.querySelector('[data-id="mask"]')).not.toBeNull() + expect(document.activeElement).toBe(container.querySelector('[data-call-tree="mask"]')) + }) + + it('点遮罩或按 Esc 关掉弹框', async () => { + function Page(): ReactElement { + const [open, setOpen] = useState(true) + return open + ? ( + <> +
卡片
+ setOpen(false)} /> + + ) + :
卡片
+ } + await render() + const overlay = document.body.querySelector('.cr-call-overlay')! + await act(async () => overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + expect(document.body.querySelector('[role="dialog"]')).toBeNull() + + await render() + await act(async () => document.body.querySelector('.cr-call-dialog')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + expect(document.body.querySelector('[role="dialog"]')).not.toBeNull() + await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))) + expect(document.body.querySelector('[role="dialog"]')).toBeNull() + expect(document.body.textContent).toContain('卡片') + }) + + it('多于一棵树才有入口切换,点一项换成那棵;只有一棵没有这排', async () => { + 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(body()).toContain('夜间对账') + expect(body()).not.toContain('另外 2 处调用') + }) + + it('正在查、没有调用者,以及类的「新建」节点', async () => { + await render( {}} />) + expect(document.body.textContent).toContain('正在查调用关系') + await render( {}} />) + expect(document.body.textContent).toContain('没有调用它的地方') + const made = node('新建 OrderService', { + kind: 'class', + detail: '进入这个类', + where: 'billing/order.py:40', + children: [node('OrderService', { kind: 'class', current: true, where: 'billing/order.py:14' })], + }) + await render( + {}} + />, + ) + expect(document.body.textContent).toContain('新建 OrderService') + expect(document.body.textContent).toContain('进入这个类') + expect(document.body.textContent).toContain('当前') + expect(document.body.textContent).not.toContain('方法') + expect(document.body.querySelector('[role="tablist"]')).toBeNull() + }) +}) diff --git a/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx b/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx new file mode 100644 index 0000000..0f97e77 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx @@ -0,0 +1,120 @@ +/** + * 调用树弹框:盖在编辑区上,预览的卡片留在后面。点遮罩、点叉、按 Esc 关掉,焦点回到打开它的按钮。 + */ + +import type { ReactElement } from 'react' +import type { CallTree, CallTreeNode } from './types' +import { Box, Braces, X } from 'lucide-react' +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' + +export function CallTreeDialog({ kind, name, where, status, trees, onClose }: { + /** 当前块的种类,整条路径用这个种类色 */ + kind: string + name: string + where: string + status: 'loading' | 'empty' | 'ready' + trees: readonly CallTree[] + onClose: () => void +}): ReactElement { + const [selected, setSelected] = useState(0) + const tree = trees[Math.min(selected, Math.max(trees.length - 1, 0))] + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Escape') + return + document.querySelector('[data-call-tree][data-active]')?.focus() + onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + function close(): void { + document.querySelector('[data-call-tree][data-active]')?.focus() + onClose() + } + + return createPortal( +
{ + if (event.target === event.currentTarget) + close() + }} + > +
+
+ 调用树 + {name && {name}} + {where && {where}} + +
+ {status === 'ready' && trees.length > 1 && ( +
+ {trees.map((item, index) => ( + + ))} +
+ )} +
+ {status === 'loading' &&

正在查调用关系

} + {status === 'empty' &&

没有调用它的地方

} + {status === 'ready' && tree && } +
+
+
, + document.body, + ) +} + +function CallNodes({ node, depth, path }: { node: CallTreeNode, depth: number, path: string }): ReactElement { + const Icon = node.kind === 'class' ? Box : Braces + return ( + <> +
+ +
+
+ + {node.name} + {node.current && 当前} +
+
+ {node.detail && {node.detail}} + {node.where} +
+
+
+ {node.children.map((child, index) => ( + + ))} + {node.otherCalls !== undefined && node.otherCalls > 0 && ( +
+ + {`另外 ${node.otherCalls} 处调用`} +
+ )} + + ) +} diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index d2a6914..a6a2bc4 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -1281,3 +1281,179 @@ del.cr-diff-word { white-space: pre; text-overflow: ellipsis; } + +/* 调用树:盖住编辑区(文件树在预览外面,不会被压暗)。路径用当前块的种类色 */ +.cr-call-count { + min-width: 14px; + height: 14px; + padding: 0 3px; + border-radius: 7px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 9px; + font-weight: 700; + line-height: 14px; + text-align: center; +} +.cr-call-overlay { + position: fixed; + inset: 0; + z-index: 40; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 32px 24px; + background: rgb(0 0 0 / 55%); +} +.cr-call-dialog { + display: flex; + flex-direction: column; + width: min(640px, 100%); + max-height: 100%; + overflow: hidden; + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent)); + border-radius: 8px; + background: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + color: var(--vscode-editorWidget-foreground, var(--vscode-foreground)); + box-shadow: 0 8px 28px var(--vscode-widget-shadow); +} +.cr-call-title { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; + height: 44px; + padding: 0 14px; + border-bottom: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, transparent)); +} +.cr-call-heading { + font-weight: 600; +} +.cr-call-symbol, +.cr-call-name { + color: var(--kind); + font-family: var(--vscode-editor-font-family, monospace); + font-weight: 600; +} +.cr-call-loc { + color: var(--vscode-disabledForeground, var(--vscode-descriptionForeground)); + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11px; +} +.cr-call-close { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin-left: auto; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground); +} +.cr-call-entries { + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + gap: 8px; + padding: 12px 16px 4px; +} +.cr-call-entry { + display: flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, transparent)); + border-radius: 6px; + background: var(--vscode-input-background, transparent); + color: var(--vscode-foreground); + font-size: 12px; +} +.cr-call-entry[aria-selected="true"] { + border-color: var(--vscode-focusBorder); + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + font-weight: 600; +} +.cr-call-hits { + color: var(--vscode-disabledForeground, var(--vscode-descriptionForeground)); + font-size: 11px; + font-weight: 400; +} +.cr-call-entry[aria-selected="true"] .cr-call-hits { + color: var(--vscode-list-activeSelectionForeground); +} +.cr-call-body { + min-height: 0; + padding: 12px 16px 16px; + overflow: auto; +} +.cr-call-note { + margin: 8px 0; + color: var(--vscode-descriptionForeground); +} +.cr-call-row { + display: flex; + align-items: stretch; + gap: 8px; +} +.cr-call-row + .cr-call-row { + margin-top: 8px; +} +.cr-call-guide { + flex-shrink: 0; + width: 2px; + border-radius: 1px; + background: var(--kind); +} +.cr-call-guide[data-folded] { + align-self: center; + height: 16px; + background: var(--vscode-editorIndentGuide-background1, var(--vscode-tree-inactiveIndentGuidesStroke)); +} +.cr-call-card { + flex: 1; + min-width: 0; + padding: 7px 10px; + border: 1px solid color-mix(in srgb, var(--kind) 35%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--kind) 7%, transparent); +} +.cr-call-card[data-current] { + border-color: var(--kind); + background: color-mix(in srgb, var(--kind) 14%, transparent); +} +.cr-call-line, +.cr-call-sub { + display: flex; + align-items: center; + gap: 8px; +} +.cr-call-line { + color: var(--kind); +} +.cr-call-sub { + margin-top: 2px; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} +.cr-call-pill { + display: flex; + flex-shrink: 0; + align-items: center; + height: 18px; + margin-left: auto; + padding: 0 6px; + border-radius: 9px; + background: var(--kind); + color: var(--vscode-editor-background); + font-size: 11px; + font-weight: 600; +} +.cr-call-folded { + align-self: center; + color: var(--vscode-disabledForeground, var(--vscode-descriptionForeground)); + font-size: 12px; +} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 8d256ef..d2fae7e 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -445,7 +445,30 @@ export interface StickyScrollSettings { * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到 * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了 * repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在 + * callTreeCounts:version 这份文档里,每个函数、类落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理 + * callTree:anchor 那块的调用树。trees 空着是没有调用它的地方。不是页面上这个版本的不理 */ +export interface CallTreeNode { + name: string + kind: string + /** 第二行。根是「入口」,新建类是「进入这个类」 */ + detail?: string + /** 文件:行,行从 1 起 */ + where: string + current: boolean + children: CallTreeNode[] + /** 不在通向当前块的路径上的调用次数 */ + otherCalls?: number +} + +/** 一棵调用树。hits 是这一棵里当前块出现几次。 */ +export interface CallTree { + id: string + name: string + hits: number + root: CallTreeNode +} + export type ExtensionToWebview = | DocumentMessage | ({ type: 'reveal' } & Reveal) @@ -469,6 +492,8 @@ export type ExtensionToWebview | { type: 'repoLinkOpened', path?: string, symbol?: string } | { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] } | { type: 'focusPathAsk' } + | { type: 'callTreeCounts', version: number, counts: Record } + | { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[] } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进) @@ -493,6 +518,7 @@ export type ExtensionToWebview * 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),扩展端按这份文件找 symbol * checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(symbols)问哪些找不到。id 标出是哪一次问 * checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问 + * openCallTree:打开 anchor 那块的调用树。version 是这块所在的文档版本 */ export type WebviewToExtension = | { type: 'ready' } @@ -516,3 +542,4 @@ export type WebviewToExtension | { type: 'openRepoLink', path?: string, symbol?: string } | { type: 'checkRepoLinks', id: number, paths: string[], symbols: string[] } | { type: 'checkRepoImages', id: number, paths: string[] } + | { type: 'openCallTree', version: number, anchor: string }