Skip to content

Commit 2147732

Browse files
committed
fix(sidebar): enhance node selection to show details for single nodes
Signed-off-by: Maystern <12112910@mail.sustech.edu.cn>
1 parent 883d3b2 commit 2147732

3 files changed

Lines changed: 227 additions & 38 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 & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -257,44 +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-
setSelectedNode(null);
266-
setCameraTarget(null);
267-
return;
268-
}
269-
setSelectedPath(path);
270-
271-
/* Single node picked from the sidebar → also show the detail panel
272-
* on the right and highlight its direct connections, matching the
273-
* behaviour of clicking a node in the 3D scene. */
274-
if (nodeIds.size === 1) {
275-
const nodeId = [...nodeIds][0];
276-
const node = filteredData.nodes.find((n) => n.id === nodeId) ?? null;
277-
setSelectedNode(node);
278-
279-
const connectedIds = new Set<number>([nodeId]);
280-
for (const edge of filteredData.edges) {
281-
if (edge.source === nodeId) connectedIds.add(edge.target);
282-
if (edge.target === nodeId) connectedIds.add(edge.source);
283-
}
284-
setHighlightedIds(connectedIds);
285-
setCameraTarget(
286-
computeCameraTarget(filteredData.nodes, connectedIds),
287-
);
288-
} else {
289-
/* Directory with multiple nodes → don't show a detail panel. */
290-
setSelectedNode(null);
291-
setHighlightedIds(nodeIds);
292-
setCameraTarget(computeCameraTarget(filteredData.nodes, nodeIds));
293-
}
294-
},
295-
[filteredData],
296-
);
297-
298260
const handleNodeClick = useCallback(
299261
(node: GraphNode) => {
300262
if (!filteredData) return;
@@ -333,6 +295,29 @@ export function GraphTab({ project }: GraphTabProps) {
333295
[handleNodeClick],
334296
);
335297

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+
336321
const toggleLabel = useCallback((label: string) => {
337322
setEnabledLabels((prev) => {
338323
const next = new Set(prev);

‎src/ui/embedded_assets.c‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* embedded_assets.c — Generated file mapping URL paths to embedded bytes.
3+
* DO NOT EDIT — regenerated by scripts/embed-frontend.sh
4+
*/
5+
#include "ui/embedded_assets.h"
6+
#include <string.h>
7+
8+
extern const unsigned char _binary_assets_index_D27RSPSK_css_data[];
9+
extern const unsigned int _binary_assets_index_D27RSPSK_css_size;
10+
extern const unsigned char _binary_assets_index_Dnf5jw6Y_js_data[];
11+
extern const unsigned int _binary_assets_index_Dnf5jw6Y_js_size;
12+
extern const unsigned char _binary_index_html_data[];
13+
extern const unsigned int _binary_index_html_size;
14+
15+
cbm_embedded_file_t CBM_EMBEDDED_FILES[] = {
16+
{"/assets/index-D27RSPSK.css", _binary_assets_index_D27RSPSK_css_data, 0, "text/css"},
17+
{"/assets/index-Dnf5jw6Y.js", _binary_assets_index_Dnf5jw6Y_js_data, 0, "application/javascript"},
18+
{"/index.html", _binary_index_html_data, 0, "text/html"},
19+
};
20+
const int CBM_EMBEDDED_FILE_COUNT = 3;
21+
22+
static void __attribute__((constructor)) init_embedded_sizes(void) {
23+
cbm_embedded_file_t *files = CBM_EMBEDDED_FILES;
24+
files[0].size = _binary_assets_index_D27RSPSK_css_size;
25+
files[1].size = _binary_assets_index_Dnf5jw6Y_js_size;
26+
files[2].size = _binary_index_html_size;
27+
}
28+
29+
const cbm_embedded_file_t *cbm_embedded_lookup(const char *path) {
30+
for (int i = 0; i < CBM_EMBEDDED_FILE_COUNT; i++) {
31+
if (strcmp(CBM_EMBEDDED_FILES[i].path, path) == 0) {
32+
return &CBM_EMBEDDED_FILES[i];
33+
}
34+
}
35+
return NULL;
36+
}

0 commit comments

Comments
 (0)