Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions graph-ui/src/components/GraphTab.selection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/* @vitest-environment jsdom */
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { GraphTab } from "./GraphTab";
import type { GraphData } from "../lib/types";

/* GraphScene renders a WebGL <Canvas> which jsdom can't run — stub it out. */
vi.mock("./GraphScene", () => ({
GraphScene: () => null,
computeCameraTarget: () => null,
}));

const SELECTION_DATA: GraphData = {
nodes: [
/* Multi-node directory: src/ has foo.ts + bar.ts */
{
id: 1,
x: 0, y: 0, z: 0,
label: "Function",
name: "foo",
file_path: "src/foo.ts",
size: 1,
color: "#ff0000",
},
{
id: 2,
x: 1, y: 0, z: 0,
label: "Class",
name: "bar",
file_path: "src/bar.ts",
size: 1,
color: "#00ff00",
},
/* One-node directory: utils/ has only helper.ts */
{
id: 3,
x: 2, y: 0, z: 0,
label: "Function",
name: "helper",
file_path: "utils/helper.ts",
size: 1,
color: "#0000ff",
},
/* Node without file_path — reachable only via search */
{
id: 4,
x: 3, y: 0, z: 0,
label: "Class",
name: "orphanFn",
size: 1,
color: "#ffff00",
},
],
edges: [
{ source: 1, target: 2, type: "CALLS" },
{ source: 1, target: 3, type: "CALLS" },
],
total_nodes: 4,
};

function mockFetch(data: GraphData) {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith("/api/layout")) {
return new Response(JSON.stringify(data), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
/* /api/ui-config, /api/repo-info → default empty */
return new Response("{}", { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

describe("GraphTab selection", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("opens the detail panel when a leaf node is clicked in the file tree", async () => {
mockFetch(SELECTION_DATA);
render(<GraphTab project="demo" />);

/* Wait for the layout to load. */
expect(await screen.findByText("Filters")).toBeInTheDocument();

/* Expand the "src" directory. */
fireEvent.click(screen.getByRole("button", { name: /src/ }));

/* Click the "foo" file node inside. */
fireEvent.click(screen.getByRole("button", { name: /^foo/ }));

/* The detail panel must render the node name as a heading. */
expect(screen.getByRole("heading", { name: "foo" })).toBeInTheDocument();
});

it("does NOT open the detail panel for a directory containing exactly one node", async () => {
mockFetch(SELECTION_DATA);
render(<GraphTab project="demo" />);

expect(await screen.findByText("Filters")).toBeInTheDocument();

/* Click the "utils" directory — it contains exactly 1 node (helper),
* but Sidebar passes no GraphNode arg for directories. */
fireEvent.click(screen.getByRole("button", { name: /utils/ }));

/* The detail panel must NOT open just because nodeIds.size === 1. */
expect(screen.queryByRole("heading", { name: "helper" })).not.toBeInTheDocument();

/* The directory IS selected though — HUD shows count. */
expect(screen.getByText("1 selected")).toBeInTheDocument();
});

it("shows the detail panel for a search result that has no file_path", async () => {
mockFetch(SELECTION_DATA);
render(<GraphTab project="demo" />);

expect(await screen.findByText("Filters")).toBeInTheDocument();

/* Search for the orphan node. */
const searchInput = screen.getByPlaceholderText("Search...");
fireEvent.change(searchInput, { target: { value: "orphan" } });

/* Click the search result — Sidebar passes "" for path (no file_path)
* but still passes the GraphNode as the third argument. */
fireEvent.click(screen.getByRole("button", { name: /orphanFn/ }));

/* The detail panel must still appear. */
expect(screen.getByRole("heading", { name: "orphanFn" })).toBeInTheDocument();
});

it("clears selection via the Sidebar 'Clear selection' button", async () => {
mockFetch(SELECTION_DATA);
render(<GraphTab project="demo" />);

expect(await screen.findByText("Filters")).toBeInTheDocument();

/* Select a leaf node first. */
fireEvent.click(screen.getByRole("button", { name: /src/ }));
fireEvent.click(screen.getByRole("button", { name: /^foo/ }));
expect(screen.getByRole("heading", { name: "foo" })).toBeInTheDocument();

/* Click "Clear selection" — there are two buttons with this label
* (Sidebar footer + HUD). Both clear, so clicking either is fine. */
const clearButtons = screen.getAllByRole("button", { name: "Clear selection" });
fireEvent.click(clearButtons[0]);

/* Detail panel must disappear. */
expect(screen.queryByRole("heading", { name: "foo" })).not.toBeInTheDocument();
});

it("highlights the selected node and its direct neighbors", async () => {
mockFetch(SELECTION_DATA);
render(<GraphTab project="demo" />);

expect(await screen.findByText("Filters")).toBeInTheDocument();

/* Select foo (id:1), which has edges to bar (id:2) and helper (id:3). */
fireEvent.click(screen.getByRole("button", { name: /src/ }));
fireEvent.click(screen.getByRole("button", { name: /^foo/ }));

/* foo + bar + helper = 3 nodes highlighted. */
expect(screen.getByText("3 selected")).toBeInTheDocument();
});
});
38 changes: 23 additions & 15 deletions graph-ui/src/components/GraphTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,21 +257,6 @@ export function GraphTab({ project }: GraphTabProps) {
};
}, [project]);

const handleSelectPath = useCallback(
(path: string, nodeIds: Set<number>) => {
if (!filteredData || !path || nodeIds.size === 0) {
setHighlightedIds(null);
setSelectedPath(null);
setCameraTarget(null);
return;
}
setSelectedPath(path);
setHighlightedIds(nodeIds);
setCameraTarget(computeCameraTarget(filteredData.nodes, nodeIds));
},
[filteredData],
);

const handleNodeClick = useCallback(
(node: GraphNode) => {
if (!filteredData) return;
Expand Down Expand Up @@ -310,6 +295,29 @@ export function GraphTab({ project }: GraphTabProps) {
[handleNodeClick],
);

const handleSelectPath = useCallback(
(path: string, nodeIds: Set<number>, node?: GraphNode) => {
if (node) {
handleNodeClick(node);
return;
}

if (!filteredData || !path || nodeIds.size === 0) {
setHighlightedIds(null);
setSelectedPath(null);
setSelectedNode(null);
setCameraTarget(null);
return;
}

setSelectedNode(null);
setSelectedPath(path);
setHighlightedIds(nodeIds);
setCameraTarget(computeCameraTarget(filteredData.nodes, nodeIds));
},
[filteredData, handleNodeClick],
);

const toggleLabel = useCallback((label: string) => {
setEnabledLabels((prev) => {
const next = new Set(prev);
Expand Down
10 changes: 5 additions & 5 deletions graph-ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useUiMessages } from "../lib/i18n";

interface SidebarProps {
nodes: GraphNode[];
onSelectPath: (path: string, nodeIds: Set<number>) => void;
onSelectPath: (path: string, nodeIds: Set<number>, node?: GraphNode) => void;
selectedPath: string | null;
}

Expand Down Expand Up @@ -61,7 +61,7 @@ function flattenSingleChild(dir: DirNode): DirNode {

function TreeItem({ dir, depth, onSelect, selectedPath }: {
dir: DirNode; depth: number;
onSelect: (path: string, ids: Set<number>) => void;
onSelect: (path: string, ids: Set<number>, node?: GraphNode) => void;
selectedPath: string | null;
}) {
const [expanded, setExpanded] = useState(false);
Expand All @@ -72,7 +72,7 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: {
return (
<div>
<button
onClick={() => { setExpanded(!expanded); onSelect(dir.fullPath, dir.nodeIds); }}
onClick={() => { setExpanded(!expanded); onSelect(dir.fullPath, dir.nodeIds, undefined); }}
className={`flex items-center gap-1.5 w-full text-left px-3 py-[5px] text-[12px] transition-colors ${
isSelected ? "bg-primary/10 text-primary" : "text-foreground/60 hover:text-foreground/80 hover:bg-white/[0.03]"
}`}
Expand All @@ -90,7 +90,7 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: {
{sortedNodes.map((gn) => (
<button
key={gn.id}
onClick={() => onSelect(dir.fullPath + "/" + gn.name, new Set([gn.id]))}
onClick={() => onSelect(dir.fullPath + "/" + gn.name, new Set([gn.id]), gn)}
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"
style={{ paddingLeft: `${(depth+1) * 16 + 12}px` }}
>
Expand Down Expand Up @@ -148,7 +148,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
filtered.map((n) => (
<button
key={n.id}
onClick={() => onSelectPath(n.file_path ?? "", new Set([n.id]))}
onClick={() => onSelectPath(n.file_path ?? "", new Set([n.id]), n)}
className="flex items-center gap-2 w-full text-left px-4 py-1.5 text-[11px] hover:bg-white/[0.03] transition-colors"
>
<span className="w-[5px] h-[5px] rounded-full shrink-0" style={{ backgroundColor: n.color }} />
Expand Down
Loading