✨ feat(vscode): 代码阅读的函数和类可以看调用树
This commit is contained in:
1 parent
8dc572bf94
commit
645542eb5c
17 files changed
+1679
-5
No files matched your search
@@ -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> = {}): 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('方法')
|
||||
})
|
||||
})
|
||||
@@ -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<CallSymbol, 'uri' | 'line' | 'character'>): 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<string, CallSymbol>()
|
||||
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<string>()
|
||||
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<string, CallEdge[]>()
|
||||
const byCaller = new Map<string, CallEdge[]>()
|
||||
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<string, number>()
|
||||
const roots: CallSymbol[] = []
|
||||
const rootSeen = new Set<string>()
|
||||
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<string>) => {
|
||||
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<string, CallSymbol>()
|
||||
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<string>): 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<string, CallEdge[]>, 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)
|
||||
}
|
||||
@@ -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<string, CallHierarchyItem>()
|
||||
const remember = (item: CallHierarchyItem): CallSymbol => {
|
||||
const symbol = toSymbol(item)
|
||||
vscodeItems.set(symbolKey(symbol), item)
|
||||
return symbol
|
||||
}
|
||||
const starts: CallSymbol[] = []
|
||||
const seen = new Set<string>()
|
||||
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<T, R>(node: CallSymbol, command: string, map: (call: T) => R): Promise<R[]> {
|
||||
const item = vscodeItems.get(symbolKey(node))
|
||||
if (!item || cancelled())
|
||||
return []
|
||||
try {
|
||||
const calls = await commands.executeCommand<T[]>(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'
|
||||
}
|
||||
}
|
||||
@@ -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<string, number> }[] = []
|
||||
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<void>((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<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
@@ -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<string, Cached>()
|
||||
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<void> {
|
||||
const src = this.source()
|
||||
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)
|
||||
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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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<readonly IncomingHit[] | undefined>,
|
||||
outgoing: (node: CallSymbol) => Promise<readonly OutgoingHit[] | undefined>,
|
||||
cancelled: () => boolean = () => false,
|
||||
): Promise<CallEdge[]> {
|
||||
if (cancelled())
|
||||
return []
|
||||
const edges: CallEdge[] = []
|
||||
const seen = new Set<string>()
|
||||
const ancestors = new Map<string, CallSymbol>()
|
||||
const walked = new Map<string, number>()
|
||||
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<string>): Promise<void> => {
|
||||
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<string, CallSymbol>()
|
||||
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<string>()
|
||||
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
|
||||
}
|
||||
@@ -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<string, number> }
|
||||
| { 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 }
|
||||
@@ -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<string, AbortController>()
|
||||
/** 最近一次发出去的文档(版本、块树、源码):问 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)
|
||||
}
|
||||
|
||||
@@ -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<DocumentDiff | null>(null)
|
||||
/** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */
|
||||
const editing = useRef(view.editing)
|
||||
/** 页面上这份文档的版本:调用树的回答不是这一版就丢掉 */
|
||||
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
|
||||
trees: CallTree[]
|
||||
} | null>(null)
|
||||
/** 块树所在的页面内容:钉住卡片时在这里面量 */
|
||||
const content = useRef<HTMLDivElement>(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 {
|
||||
</div>
|
||||
{sticky?.enabled && <StickyLines content={content} blocks={doc.blocks} settings={sticky} />}
|
||||
<DefinitionPeekPopup shown={peek.shown} onJump={peek.jump} />
|
||||
{openCall && (
|
||||
<CallTreeDialog
|
||||
kind={openCall.kind}
|
||||
name={openCall.name}
|
||||
where={openCall.where}
|
||||
status={openCall.status === 'loading' ? 'loading' : openCall.trees.length === 0 ? 'empty' : 'ready'}
|
||||
trees={openCall.trees}
|
||||
onClose={() => setOpenCall(null)}
|
||||
/>
|
||||
)}
|
||||
</DisplayNameContext>
|
||||
</HighlightContext>
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
<BlockTools
|
||||
editing={editing}
|
||||
onToggleEdit={() => {}}
|
||||
{...(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(/<button[^>]*>/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(/<button/g)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { Eye, MessageSquareText, Pencil, Sparkles } from 'lucide-react'
|
||||
import { Eye, GitBranch, MessageSquareText, Pencil, Sparkles } from 'lucide-react'
|
||||
|
||||
/** 「重写注释」按钮的样子和动作:fresh 是块还没有注释、点了新写一段;正在重写时不能再点。 */
|
||||
export interface RewriteTool {
|
||||
@@ -14,7 +14,7 @@ export interface RewriteTool {
|
||||
* 「问 AI」:点开 / 收起块下面的问答区,展开时带按下的底色。
|
||||
* 「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。
|
||||
*/
|
||||
export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite }: {
|
||||
export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite, callTree }: {
|
||||
editing: boolean
|
||||
onToggleEdit: () => void
|
||||
/** 块下面的问答区展开着 */
|
||||
@@ -23,6 +23,8 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite
|
||||
onToggleAsk?: () => void
|
||||
/** 没给就不显示「按注释规范重写」(不能写注释的块) */
|
||||
rewrite?: RewriteTool
|
||||
/** 没给,或棵数是 0,就不显示调用树(还没查到、没有调用者、语言不提供) */
|
||||
callTree?: { anchor: string, count: number, open: boolean, onOpen: () => void }
|
||||
}): ReactElement {
|
||||
const label = editing ? '预览' : '编辑'
|
||||
const Icon = editing ? Eye : Pencil
|
||||
@@ -65,6 +67,21 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite
|
||||
>
|
||||
<Icon size={14} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
{callTree && callTree.count > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="cr-tool flex h-5 items-center justify-center gap-px rounded px-1"
|
||||
data-call-tree={callTree.anchor}
|
||||
data-active={callTree.open ? '' : undefined}
|
||||
data-tooltip="调用树"
|
||||
aria-label="调用树"
|
||||
aria-expanded={callTree.open}
|
||||
onClick={callTree.onOpen}
|
||||
>
|
||||
<GitBranch size={14} strokeWidth={1.8} aria-hidden />
|
||||
<span className="cr-call-count">{callTree.count}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,9 +37,18 @@ export interface EditTree {
|
||||
onStopRewrite: (anchor: string) => void
|
||||
}
|
||||
|
||||
/** 函数、类落在几棵调用树里。定义浮窗不传,跟问 AI 一样不画这排按钮。 */
|
||||
export interface CallTreeTools {
|
||||
counts: ReadonlyMap<string, number>
|
||||
openAnchor?: string
|
||||
onOpen: (anchor: string) => void
|
||||
}
|
||||
|
||||
export interface BlockTreeState {
|
||||
/** 能编辑(页面上的块树);定义卡片浮窗里没有 */
|
||||
edit?: EditTree
|
||||
/** 调用树。只读的树不传 */
|
||||
callTree?: CallTreeTools
|
||||
/** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */
|
||||
collapsed: ReadonlySet<string>
|
||||
/** 按筛选不画的块(连同子孙),见 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)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<void> {
|
||||
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<string>(),
|
||||
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']> = {}): 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(<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="statement"]')).toBeNull()
|
||||
expect(container.querySelector('[data-call-tree="class"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-call-tree="class"]')!.textContent).toBe('2')
|
||||
|
||||
await render(<BlockLevel blocks={[block('function')]} tree={tree({ counts: new Map([['function', 3]]), onOpen: () => {} }, 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 (
|
||||
<>
|
||||
<BlockLevel
|
||||
blocks={[card]}
|
||||
tree={tree({ counts: new Map([['mask', 3]]), openAnchor: open ? 'mask' : undefined, onOpen: () => setOpen(true) })}
|
||||
/>
|
||||
{open && (
|
||||
<CallTreeDialog
|
||||
kind="function"
|
||||
name="mask_phone"
|
||||
where="utils/phone.py:28"
|
||||
status="ready"
|
||||
trees={[ONE]}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
await render(<Page />)
|
||||
const button = container.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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
|
||||
? (
|
||||
<>
|
||||
<div data-id="mask">卡片</div>
|
||||
<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={[ONE]} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
)
|
||||
: <div data-id="mask">卡片</div>
|
||||
}
|
||||
await render(<Page key="overlay" />)
|
||||
const overlay = document.body.querySelector<HTMLElement>('.cr-call-overlay')!
|
||||
await act(async () => overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
|
||||
await render(<Page key="esc" />)
|
||||
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(<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={MANY} onClose={() => {}} />)
|
||||
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(<CallTreeDialog kind="class" name="" where="" status="loading" trees={[]} onClose={() => {}} />)
|
||||
expect(document.body.textContent).toContain('正在查调用关系')
|
||||
await render(<CallTreeDialog kind="class" name="OrderService" where="billing/order.py:14" status="empty" trees={[]} onClose={() => {}} />)
|
||||
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(
|
||||
<CallTreeDialog
|
||||
kind="class"
|
||||
name="OrderService"
|
||||
where="billing/order.py:14"
|
||||
status="ready"
|
||||
trees={[{ id: 'post', name: 'POST /orders', hits: 1, root: node('POST /orders', { detail: '入口', kind: 'function', children: [made] }) }]}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLButtonElement>('[data-call-tree][data-active]')?.focus()
|
||||
onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
function close(): void {
|
||||
document.querySelector<HTMLButtonElement>('[data-call-tree][data-active]')?.focus()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="cr-call-overlay"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={name ? `调用树 ${name}` : '调用树'}
|
||||
className="cr-call-dialog"
|
||||
data-kind={kind}
|
||||
>
|
||||
<div className="cr-call-title">
|
||||
<span className="cr-call-heading">调用树</span>
|
||||
{name && <span className="cr-call-symbol">{name}</span>}
|
||||
{where && <span className="cr-call-loc">{where}</span>}
|
||||
<button type="button" className="cr-tool cr-call-close" aria-label="关闭" onClick={close}>
|
||||
<X size={16} strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
{status === 'ready' && trees.length > 1 && (
|
||||
<div className="cr-call-entries" role="tablist" aria-label="调用树入口">
|
||||
{trees.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={index === selected}
|
||||
data-active={index === selected ? '' : undefined}
|
||||
className="cr-call-entry"
|
||||
onClick={() => setSelected(index)}
|
||||
>
|
||||
<span>{item.name}</span>
|
||||
<span className="cr-call-hits">{`${item.hits} 处`}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="cr-call-body">
|
||||
{status === 'loading' && <p className="cr-call-note">正在查调用关系</p>}
|
||||
{status === 'empty' && <p className="cr-call-note">没有调用它的地方</p>}
|
||||
{status === 'ready' && tree && <CallNodes node={tree.root} depth={0} path={tree.id} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
function CallNodes({ node, depth, path }: { node: CallTreeNode, depth: number, path: string }): ReactElement {
|
||||
const Icon = node.kind === 'class' ? Box : Braces
|
||||
return (
|
||||
<>
|
||||
<div className="cr-call-row" style={{ paddingLeft: depth * 22 }}>
|
||||
<span className="cr-call-guide" />
|
||||
<div className="cr-call-card" data-current={node.current ? '' : undefined}>
|
||||
<div className="cr-call-line">
|
||||
<Icon size={14} strokeWidth={1.8} aria-hidden />
|
||||
<span className="cr-call-name">{node.name}</span>
|
||||
{node.current && <span className="cr-call-pill">当前</span>}
|
||||
</div>
|
||||
<div className="cr-call-sub">
|
||||
{node.detail && <span>{node.detail}</span>}
|
||||
<span className="cr-call-loc">{node.where}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{node.children.map((child, index) => (
|
||||
<CallNodes key={`${path}/${index}`} node={child} depth={depth + 1} path={`${path}/${index}`} />
|
||||
))}
|
||||
{node.otherCalls !== undefined && node.otherCalls > 0 && (
|
||||
<div className="cr-call-row" style={{ paddingLeft: (depth + 1) * 22 }}>
|
||||
<span className="cr-call-guide" data-folded="" />
|
||||
<span className="cr-call-folded">{`另外 ${node.otherCalls} 处调用`}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, number> }
|
||||
| { 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 }
|
||||
Reference in new issue
Block a user