Compare commits

...
Author SHA1 Message Date
l0ng-ai 7cfe01122b fix(hierarchy): show no expand arrow on types without subtypes
The type hierarchy loaded a level only when its node was expanded, so every
type showed an expand arrow, also one without subtypes. The level below each
shown type is now loaded ahead, four requests at a time, so a leaf shows as
one. Call hierarchies stay lazy.

Fixes #2471
2026-11-10 23:34:28 +08:00
3 changed files with 81 additions and 2 deletions

No files matched your search

@@ -23,6 +23,7 @@ import {
hierarchyTitle,
hierarchyTarget,
loadChildren,
prefetchLevels,
refreshHierarchy,
noneFoundLabel,
setDirection,
@@ -222,6 +223,10 @@ export function HierarchyView() {
getChildren={getChildren}
isExpandable={isExpandable}
loadChildren={(n) => (n.kind === "item" ? loadChildren(n.node) : undefined)}
onExpandedChange={(_expanded, n, open) => {
const shown = open ? children.get(n.id) : undefined;
if (shown) prefetchLevels(shown);
}}
defaultExpanded={[ROOT_ID]}
handleRef={tree}
isOccurrence={() => true}
@@ -55,6 +55,20 @@ const callHierarchy = vi.fn((): { items: HierarchyItem[]; incomplete: References
incomplete: null,
}));
const cancelRequest = vi.fn(async () => undefined);
// Subtypes by the line of the type asked about: the caret's type → Long (30), Striped64 (40); Striped64 → LongAdder (50).
const typeItem = (name: string, line: number): HierarchyItem => ({
...item(name),
kind: "class",
path: "/p/Types.java",
selectionRange: range(line, 0, line, name.length),
});
const typeHierarchy = vi.fn((pos: { line: number; col: number }): HierarchyItem[] =>
pos.line === 40
? [typeItem("LongAdder", 50)]
: pos.line < 30
? [typeItem("Long", 30), typeItem("Striped64", 40)]
: [],
);
// 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;
@@ -67,6 +81,7 @@ vi.mock("@/platform/ipc", async (orig) => {
...real.commands,
docOutline: async () => OUTLINE,
cancelRequest,
lspTypeHierarchy: async (_doc: number, pos: { line: number; col: number }) => typeHierarchy(pos),
lspCallHierarchy: async (_request: number, _doc: number, pos: { line: number; col: number }) => {
// The server prepares a hierarchy only on the method name.
if (!servedAt(pos)) throw new real.IpcError({ kind: "notFound", message: "No symbol" });
@@ -169,6 +184,28 @@ describe("hierarchy", () => {
});
});
describe("type hierarchy", () => {
it("loads each shown type's level ahead, so a type without subtypes shows no expand arrow", async () => {
servedAt = () => true;
try {
useHierarchy.setState({ mode: "type", direction: "outgoing" });
await showHierarchy("type");
const [long, striped] = useHierarchy.getState().children.get(ROOT_ID) ?? [];
await vi.waitFor(() => {
const { children } = useHierarchy.getState();
expect(children.get(long!.id)).toEqual([]);
expect(children.get(striped!.id)?.map((n) => n.item.name)).toEqual(["LongAdder"]);
});
const { children } = useHierarchy.getState();
// Only the shown levels: LongAdder's subtypes wait until Striped64 is expanded.
expect(children.has(children.get(striped!.id)![0]!.id)).toBe(false);
} finally {
servedAt = (pos) => pos.line === 2 && pos.col === 7;
useHierarchy.setState({ mode: "call", direction: "incoming" });
}
});
});
describe("closing the hierarchy tab", () => {
it("clears the tree and hides the tool window", async () => {
const { closeHierarchy, useHierarchy } = await import("./hierarchy");
+39 -2
View File
@@ -177,6 +177,7 @@ async function loadRoot(quiet = false): Promise<RootResult> {
request(s.mode, doc, root.line, root.col, s.direction),
);
setChildren(generation, ROOT_ID, [rootKey(root)], items, incomplete && incompleteNote(incomplete, root.name));
prefetchLevels(useHierarchy.getState().children.get(ROOT_ID) ?? []);
return "loaded";
} catch (e) {
if (useHierarchy.getState().generation !== generation) return "stale";
@@ -220,7 +221,8 @@ async function enclosingRoot(c: CaretContext, mode: HierarchyMode): Promise<Hier
return null;
}
}
export async function loadChildren(node: HierarchyNode) {
/** Loads `node`'s level. `ahead`: a look-ahead load (prefetchLevels), which reports nothing. */
export async function loadChildren(node: HierarchyNode, ahead = false) {
const { mode, direction, generation } = useHierarchy.getState();
const at = node.item.selectionRange.start;
try {
@@ -234,8 +236,9 @@ export async function loadChildren(node: HierarchyNode) {
items,
incomplete && incompleteNote(incomplete, node.item.name),
);
if (!ahead) prefetchLevels(useHierarchy.getState().children.get(node.id) ?? []);
} catch (e) {
if (useHierarchy.getState().generation !== generation) return;
if (ahead || useHierarchy.getState().generation !== generation) return;
useHierarchy.setState((st) => ({
failed: new Set(st.failed).add(node.id),
children: new Map(st.children).set(node.id, []),
@@ -244,6 +247,40 @@ export async function loadChildren(node: HierarchyNode) {
}
}
const AHEAD_CONCURRENCY = 4;
const ahead: HierarchyNode[] = [];
let aheadRunning = 0;
let aheadGeneration = -1;
/**
* IDEA's type hierarchy shows no expand arrow on a type without subtypes (or supertypes): the
* level below each shown node is loaded ahead, a few requests at a time. Call hierarchies stay
* lazy, as in IDEA.
*/
export function prefetchLevels(nodes: readonly HierarchyNode[]) {
const { mode, generation, children } = useHierarchy.getState();
if (mode !== "type") return;
if (aheadGeneration !== generation) {
ahead.length = 0;
aheadGeneration = generation;
}
for (const n of nodes) if (!n.recursion && !children.has(n.id) && !ahead.includes(n)) ahead.push(n);
pumpAhead();
}
function pumpAhead() {
while (aheadRunning < AHEAD_CONCURRENCY && ahead.length) {
const node = ahead.shift()!;
const { generation, children } = useHierarchy.getState();
if (generation !== aheadGeneration || children.has(node.id)) continue;
aheadRunning++;
void loadChildren(node, true).finally(() => {
aheadRunning--;
pumpAhead();
});
}
}
export async function showHierarchy(mode: HierarchyMode) {
const c = caretContext();
if (!c) {