Skip to content

Commit 4149c50

Browse files
authored
Merge pull request DeusData#1235 from Maystern/fix/sidebar-show-node-and-details
fix(sidebar): enhance node selection to show details for single nodes
2 parents e65ace1 + 6206f86 commit 4149c50

3 files changed

Lines changed: 196 additions & 20 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/* @vitest-environment jsdom */
2+
import "@testing-library/jest-dom/vitest";
3+
import { fireEvent, render, screen } from "@testing-library/react";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { GraphTab } from "./GraphTab";
6+
import type { GraphData } from "../lib/types";
7+
8+
/* GraphScene renders a WebGL <Canvas> which jsdom can't run — stub it out. */
9+
vi.mock("./GraphScene", () => ({
10+
GraphScene: () => null,
11+
computeCameraTarget: () => null,
12+
}));
13+
14+
const SELECTION_DATA: GraphData = {
15+
nodes: [
16+
/* Multi-node directory: src/ has foo.ts + bar.ts */
17+
{
18+
id: 1,
19+
x: 0, y: 0, z: 0,
20+
label: "Function",
21+
name: "foo",
22+
file_path: "src/foo.ts",
23+
size: 1,
24+
color: "#ff0000",
25+
},
26+
{
27+
id: 2,
28+
x: 1, y: 0, z: 0,
29+
label: "Class",
30+
name: "bar",
31+
file_path: "src/bar.ts",
32+
size: 1,
33+
color: "#00ff00",
34+
},
35+
/* One-node directory: utils/ has only helper.ts */
36+
{
37+
id: 3,
38+
x: 2, y: 0, z: 0,
39+
label: "Function",
40+
name: "helper",
41+
file_path: "utils/helper.ts",
42+
size: 1,
43+
color: "#0000ff",
44+
},
45+
/* Node without file_path — reachable only via search */
46+
{
47+
id: 4,
48+
x: 3, y: 0, z: 0,
49+
label: "Class",
50+
name: "orphanFn",
51+
size: 1,
52+
color: "#ffff00",
53+
},
54+
],
55+
edges: [
56+
{ source: 1, target: 2, type: "CALLS" },
57+
{ source: 1, target: 3, type: "CALLS" },
58+
],
59+
total_nodes: 4,
60+
};
61+
62+
function mockFetch(data: GraphData) {
63+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
64+
const url = String(input);
65+
if (url.startsWith("/api/layout")) {
66+
return new Response(JSON.stringify(data), {
67+
status: 200,
68+
headers: { "Content-Type": "application/json" },
69+
});
70+
}
71+
/* /api/ui-config, /api/repo-info → default empty */
72+
return new Response("{}", { status: 200 });
73+
});
74+
vi.stubGlobal("fetch", fetchMock);
75+
return fetchMock;
76+
}
77+
78+
describe("GraphTab selection", () => {
79+
afterEach(() => {
80+
vi.unstubAllGlobals();
81+
});
82+
83+
it("opens the detail panel when a leaf node is clicked in the file tree", async () => {
84+
mockFetch(SELECTION_DATA);
85+
render(<GraphTab project="demo" />);
86+
87+
/* Wait for the layout to load. */
88+
expect(await screen.findByText("Filters")).toBeInTheDocument();
89+
90+
/* Expand the "src" directory. */
91+
fireEvent.click(screen.getByRole("button", { name: /src/ }));
92+
93+
/* Click the "foo" file node inside. */
94+
fireEvent.click(screen.getByRole("button", { name: /^foo/ }));
95+
96+
/* The detail panel must render the node name as a heading. */
97+
expect(screen.getByRole("heading", { name: "foo" })).toBeInTheDocument();
98+
});
99+
100+
it("does NOT open the detail panel for a directory containing exactly one node", async () => {
101+
mockFetch(SELECTION_DATA);
102+
render(<GraphTab project="demo" />);
103+
104+
expect(await screen.findByText("Filters")).toBeInTheDocument();
105+
106+
/* Click the "utils" directory — it contains exactly 1 node (helper),
107+
* but Sidebar passes no GraphNode arg for directories. */
108+
fireEvent.click(screen.getByRole("button", { name: /utils/ }));
109+
110+
/* The detail panel must NOT open just because nodeIds.size === 1. */
111+
expect(screen.queryByRole("heading", { name: "helper" })).not.toBeInTheDocument();
112+
113+
/* The directory IS selected though — HUD shows count. */
114+
expect(screen.getByText("1 selected")).toBeInTheDocument();
115+
});
116+
117+
it("shows the detail panel for a search result that has no file_path", async () => {
118+
mockFetch(SELECTION_DATA);
119+
render(<GraphTab project="demo" />);
120+
121+
expect(await screen.findByText("Filters")).toBeInTheDocument();
122+
123+
/* Search for the orphan node. */
124+
const searchInput = screen.getByPlaceholderText("Search...");
125+
fireEvent.change(searchInput, { target: { value: "orphan" } });
126+
127+
/* Click the search result — Sidebar passes "" for path (no file_path)
128+
* but still passes the GraphNode as the third argument. */
129+
fireEvent.click(screen.getByRole("button", { name: /orphanFn/ }));
130+
131+
/* The detail panel must still appear. */
132+
expect(screen.getByRole("heading", { name: "orphanFn" })).toBeInTheDocument();
133+
});
134+
135+
it("clears selection via the Sidebar 'Clear selection' button", async () => {
136+
mockFetch(SELECTION_DATA);
137+
render(<GraphTab project="demo" />);
138+
139+
expect(await screen.findByText("Filters")).toBeInTheDocument();
140+
141+
/* Select a leaf node first. */
142+
fireEvent.click(screen.getByRole("button", { name: /src/ }));
143+
fireEvent.click(screen.getByRole("button", { name: /^foo/ }));
144+
expect(screen.getByRole("heading", { name: "foo" })).toBeInTheDocument();
145+
146+
/* Click "Clear selection" — there are two buttons with this label
147+
* (Sidebar footer + HUD). Both clear, so clicking either is fine. */
148+
const clearButtons = screen.getAllByRole("button", { name: "Clear selection" });
149+
fireEvent.click(clearButtons[0]);
150+
151+
/* Detail panel must disappear. */
152+
expect(screen.queryByRole("heading", { name: "foo" })).not.toBeInTheDocument();
153+
});
154+
155+
it("highlights the selected node and its direct neighbors", async () => {
156+
mockFetch(SELECTION_DATA);
157+
render(<GraphTab project="demo" />);
158+
159+
expect(await screen.findByText("Filters")).toBeInTheDocument();
160+
161+
/* Select foo (id:1), which has edges to bar (id:2) and helper (id:3). */
162+
fireEvent.click(screen.getByRole("button", { name: /src/ }));
163+
fireEvent.click(screen.getByRole("button", { name: /^foo/ }));
164+
165+
/* foo + bar + helper = 3 nodes highlighted. */
166+
expect(screen.getByText("3 selected")).toBeInTheDocument();
167+
});
168+
});

‎graph-ui/src/components/GraphTab.tsx‎

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -257,21 +257,6 @@ export function GraphTab({ project }: GraphTabProps) {
257257
};
258258
}, [project]);
259259

260-
const handleSelectPath = useCallback(
261-
(path: string, nodeIds: Set<number>) => {
262-
if (!filteredData || !path || nodeIds.size === 0) {
263-
setHighlightedIds(null);
264-
setSelectedPath(null);
265-
setCameraTarget(null);
266-
return;
267-
}
268-
setSelectedPath(path);
269-
setHighlightedIds(nodeIds);
270-
setCameraTarget(computeCameraTarget(filteredData.nodes, nodeIds));
271-
},
272-
[filteredData],
273-
);
274-
275260
const handleNodeClick = useCallback(
276261
(node: GraphNode) => {
277262
if (!filteredData) return;
@@ -310,6 +295,29 @@ export function GraphTab({ project }: GraphTabProps) {
310295
[handleNodeClick],
311296
);
312297

298+
const handleSelectPath = useCallback(
299+
(path: string, nodeIds: Set<number>, node?: GraphNode) => {
300+
if (node) {
301+
handleNodeClick(node);
302+
return;
303+
}
304+
305+
if (!filteredData || !path || nodeIds.size === 0) {
306+
setHighlightedIds(null);
307+
setSelectedPath(null);
308+
setSelectedNode(null);
309+
setCameraTarget(null);
310+
return;
311+
}
312+
313+
setSelectedNode(null);
314+
setSelectedPath(path);
315+
setHighlightedIds(nodeIds);
316+
setCameraTarget(computeCameraTarget(filteredData.nodes, nodeIds));
317+
},
318+
[filteredData, handleNodeClick],
319+
);
320+
313321
const toggleLabel = useCallback((label: string) => {
314322
setEnabledLabels((prev) => {
315323
const next = new Set(prev);

‎graph-ui/src/components/Sidebar.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useUiMessages } from "../lib/i18n";
55

66
interface SidebarProps {
77
nodes: GraphNode[];
8-
onSelectPath: (path: string, nodeIds: Set<number>) => void;
8+
onSelectPath: (path: string, nodeIds: Set<number>, node?: GraphNode) => void;
99
selectedPath: string | null;
1010
}
1111

@@ -61,7 +61,7 @@ function flattenSingleChild(dir: DirNode): DirNode {
6161

6262
function TreeItem({ dir, depth, onSelect, selectedPath }: {
6363
dir: DirNode; depth: number;
64-
onSelect: (path: string, ids: Set<number>) => void;
64+
onSelect: (path: string, ids: Set<number>, node?: GraphNode) => void;
6565
selectedPath: string | null;
6666
}) {
6767
const [expanded, setExpanded] = useState(false);
@@ -72,7 +72,7 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: {
7272
return (
7373
<div>
7474
<button
75-
onClick={() => { setExpanded(!expanded); onSelect(dir.fullPath, dir.nodeIds); }}
75+
onClick={() => { setExpanded(!expanded); onSelect(dir.fullPath, dir.nodeIds, undefined); }}
7676
className={`flex items-center gap-1.5 w-full text-left px-3 py-[5px] text-[12px] transition-colors ${
7777
isSelected ? "bg-primary/10 text-primary" : "text-foreground/60 hover:text-foreground/80 hover:bg-white/[0.03]"
7878
}`}
@@ -90,7 +90,7 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: {
9090
{sortedNodes.map((gn) => (
9191
<button
9292
key={gn.id}
93-
onClick={() => onSelect(dir.fullPath + "/" + gn.name, new Set([gn.id]))}
93+
onClick={() => onSelect(dir.fullPath + "/" + gn.name, new Set([gn.id]), gn)}
9494
className="flex items-center gap-1.5 w-full text-left px-3 py-[3px] text-[11px] text-foreground/40 hover:text-foreground/60 hover:bg-white/[0.02] transition-colors"
9595
style={{ paddingLeft: `${(depth+1) * 16 + 12}px` }}
9696
>
@@ -148,7 +148,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
148148
filtered.map((n) => (
149149
<button
150150
key={n.id}
151-
onClick={() => onSelectPath(n.file_path ?? "", new Set([n.id]))}
151+
onClick={() => onSelectPath(n.file_path ?? "", new Set([n.id]), n)}
152152
className="flex items-center gap-2 w-full text-left px-4 py-1.5 text-[11px] hover:bg-white/[0.03] transition-colors"
153153
>
154154
<span className="w-[5px] h-[5px] rounded-full shrink-0" style={{ backgroundColor: n.color }} />

0 commit comments

Comments
 (0)