diff --git a/src/features/code-insight/HierarchyView.tsx b/src/features/code-insight/HierarchyView.tsx index 6e39047c..5f6ce0fc 100644 --- a/src/features/code-insight/HierarchyView.tsx +++ b/src/features/code-insight/HierarchyView.tsx @@ -183,6 +183,11 @@ export function HierarchyView() { {basename(item.path)}:{item.selectionRange.start.line + 1} {calls > 1 && {tx`(${calls} calls)`}} + {n.node.recursion && ( + + recursive + + )} {notes.has(n.id) && ( {notes.get(n.id)} diff --git a/src/features/code-insight/hierarchy.test.ts b/src/features/code-insight/hierarchy.test.ts index 46663183..8c2120ee 100644 --- a/src/features/code-insight/hierarchy.test.ts +++ b/src/features/code-insight/hierarchy.test.ts @@ -54,6 +54,8 @@ const callHierarchy = vi.fn((): { items: HierarchyItem[]; incomplete: References incomplete: null, })); const cancelRequest = vi.fn(async () => undefined); +// The positions a hierarchy request is answered at; anything else is "no symbol". +let servedAt = (pos: { line: number; col: number }) => pos.line === 2 && pos.col === 7; vi.mock("@/platform/ipc", async (orig) => { const real = await orig(); @@ -66,7 +68,7 @@ vi.mock("@/platform/ipc", async (orig) => { cancelRequest, lspCallHierarchy: async (_request: number, _doc: number, pos: { line: number; col: number }) => { // The server prepares a hierarchy only on the method name. - if (pos.line !== 2 || pos.col !== 7) throw new real.IpcError({ kind: "notFound", message: "No symbol" }); + if (!servedAt(pos)) throw new real.IpcError({ kind: "notFound", message: "No symbol" }); return callHierarchy(); }, }, @@ -83,6 +85,7 @@ vi.mock("./ipc", async (orig) => ({ const { enclosingDeclaration, hierarchyTarget, + loadChildren, refreshHierarchy, resetHierarchy, showHierarchy, @@ -143,4 +146,24 @@ describe("hierarchy", () => { expect(cancelRequest).toHaveBeenCalledTimes(1); answer({ items: [item("stale")], incomplete: null }); }); + + it("keeps a caller that repeats an ancestor a recursion leaf", async () => { + servedAt = () => true; // `main` calls itself: every level answers with the same item. + try { + useHierarchy.setState({ mode: "call", direction: "incoming" }); + await showHierarchy("call"); + const [top] = useHierarchy.getState().children.get(ROOT_ID) ?? []; + expect(top?.item.name).toBe("main"); + expect(top?.recursion).toBeUndefined(); + await loadChildren(top!); + const nested = useHierarchy.getState().children.get(top!.id)?.[0]; + expect(nested?.item.name).toBe("main"); + expect(nested?.recursion).toBe(true); + // The leaf carries no expandable children, so the tree never requests it again. + expect(useHierarchy.getState().children.get(nested!.id)).toEqual([]); + expect(useHierarchy.getState().failed.size).toBe(0); + } finally { + servedAt = (pos) => pos.line === 2 && pos.col === 7; + } + }); }); diff --git a/src/features/code-insight/hierarchy.ts b/src/features/code-insight/hierarchy.ts index cfd3f2f4..ccbaca6f 100644 --- a/src/features/code-insight/hierarchy.ts +++ b/src/features/code-insight/hierarchy.ts @@ -13,6 +13,8 @@ export type HierarchyMode = "call" | "type"; export interface HierarchyNode { id: string; item: HierarchyItem; + /** Set when the node repeats an ancestor: it is kept a leaf and marked "recursive". */ + recursion?: boolean; } export interface HierarchyRoot { @@ -107,19 +109,49 @@ export function childId(parent: string, item: HierarchyItem): string { return `${parent}/${item.path}:${p.line}:${p.col}:${item.name}`; } -function setChildren(generation: number, parent: string, items: HierarchyItem[], note: string | null) { +/** Position-unique key of a hierarchy item: the same symbol always has the same one. */ +function itemKey(item: HierarchyItem): string { + const p = item.selectionRange.start; + return `${item.path}:${p.line}:${p.col}:${item.name}`; +} + +/** The root's key, matching itemKey of the item its position resolves to. */ +function rootKey(root: HierarchyRoot): string { + return `${root.path}:${root.line}:${root.col}:${root.name}`; +} + +/** Each node's position key plus its ancestors', so a child repeating one is a cycle. */ +const nodeKeys = new WeakMap(); + +function setChildren( + generation: number, + parent: string, + parentKeys: readonly string[], + items: HierarchyItem[], + note: string | null, +) { const s = useHierarchy.getState(); if (s.generation !== generation) return; + const ancestors = new Set(parentKeys); const seen = new Set(); const nodes: HierarchyNode[] = []; + const children = new Map(s.children); + children.set(parent, nodes); for (const item of items) { const id = childId(parent, item); if (seen.has(id)) continue; seen.add(id); - nodes.push({ id, item }); + const key = itemKey(item); + // A recursive symbol arrives with a fresh id on every level (the id carries the + // parent chain); only its position key reveals the cycle. Keep such a node a + // leaf — expanding it again would request the same level forever. + const node: HierarchyNode = ancestors.has(key) ? { id, item, recursion: true } : { id, item }; + if (node.recursion) children.set(id, []); + nodeKeys.set(node, [...parentKeys, key]); + nodes.push(node); } useHierarchy.setState({ - children: new Map(s.children).set(parent, nodes), + children, ...(note ? { notes: new Map(s.notes).set(parent, note) } : {}), }); } @@ -138,7 +170,7 @@ async function loadRoot(quiet = false): Promise { const { items, incomplete } = await withDocument(root.path, (doc) => request(s.mode, doc, root.line, root.col, s.direction), ); - setChildren(generation, ROOT_ID, items, incomplete && incompleteNote(incomplete, root.name)); + setChildren(generation, ROOT_ID, [rootKey(root)], items, incomplete && incompleteNote(incomplete, root.name)); return "loaded"; } catch (e) { if (useHierarchy.getState().generation !== generation) return "stale"; @@ -189,7 +221,13 @@ export async function loadChildren(node: HierarchyNode) { const { items, incomplete } = await withDocument(node.item.path, (doc) => request(mode, doc, at.line, at.col, direction), ); - setChildren(generation, node.id, items, incomplete && incompleteNote(incomplete, node.item.name)); + setChildren( + generation, + node.id, + nodeKeys.get(node) ?? [], + items, + incomplete && incompleteNote(incomplete, node.item.name), + ); } catch (e) { if (useHierarchy.getState().generation !== generation) return; useHierarchy.setState((st) => ({ diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d7b36d69..28f805a2 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5551,5 +5551,6 @@ "e.g. --keepdb --parallel": "例如 --keepdb --parallel", "select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改", "select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改", - "External file changes sync may be slow": "外部文件更改同步可能较慢" + "External file changes sync may be slow": "外部文件更改同步可能较慢", + "recursive": "递归" }