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": "递归"
}