feat(ui): CBM Atlas region scene, deep links, sidebar selection, uncapped connections

The graph tab becomes CBM Atlas and gains the region level as its default
for large projects:

- Projects above 5,000 nodes open on the REGION SCENE: one body per Leiden
  call community (folder groups as fallback), weighted arcs between them,
  a region list on the left and a region panel (provenance, cohesion, top
  callables) on the right. Double-click or 'Open region' loads that
  region's full-detail layout (scope=region:N) with a '< Regions'
  breadcrumb; 'Load full galaxy' keeps the classic whole-graph view one
  click away. Small projects skip the region hop entirely.
- Deep links (#564): ?node= and ?region= join ?tab=&project= — a selected
  node or opened region survives reload and back/forward, and can be
  shared.
- #1197: clicking a symbol row in the sidebar (tree or search) selects the
  node itself — scene highlight AND detail panel; selecting a directory
  clears a stale panel instead of leaving the previous node in it.
- #1198: connection lists page with 'Show N more' instead of a silent cap
  of 25.
- Rename: the UI announces itself as CBM Atlas (header + title).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-08-23 22:22:56 +02:00
parent 499eeba51b
commit bbbc015304
15 changed files with 885 additions and 55 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codebase Memory — Graph</title>
<title>CBM Atlas</title>
</head>
<body class="bg-[#0a0a10] text-[#e4e4ed] min-h-screen">
<div id="root"></div>
+49 -12
View File
@@ -10,6 +10,10 @@ const TAB_IDS: TabId[] = ["graph", "stats", "control"];
interface RouteState {
tab: TabId;
project: string | null;
/* Deep links: a selected node and/or an opened region survive reloads and
* can be shared (#564). Values are the server-assigned ids. */
node: string | null;
region: string | null;
}
/* Read the active tab + selected project from the URL query string so the
@@ -19,14 +23,28 @@ function readRoute(): RouteState {
const rawTab = params.get("tab");
const tab = TAB_IDS.includes(rawTab as TabId) ? (rawTab as TabId) : "stats";
const project = params.get("project");
return { tab, project: project ? project : null };
const node = params.get("node");
const region = params.get("region");
return {
tab,
project: project ? project : null,
node: node ? node : null,
region: region ? region : null,
};
}
/* Build the canonical URL for a route, preserving the path and hash. */
function routeUrl(tab: TabId, project: string | null): string {
function routeUrl(
tab: TabId,
project: string | null,
node?: string | null,
region?: string | null,
): string {
const params = new URLSearchParams();
params.set("tab", tab);
if (project) params.set("project", project);
if (region) params.set("region", region);
if (node) params.set("node", node);
return `${window.location.pathname}?${params.toString()}${window.location.hash}`;
}
@@ -38,7 +56,11 @@ export function App() {
/* Normalize the URL on first load so it always carries the current route. */
useEffect(() => {
const initial = readRoute();
window.history.replaceState(null, "", routeUrl(initial.tab, initial.project));
window.history.replaceState(
null,
"",
routeUrl(initial.tab, initial.project, initial.node, initial.region),
);
}, []);
/* Sync state when the user navigates with the browser back/forward buttons. */
@@ -49,13 +71,21 @@ export function App() {
}, []);
/* Change the route and push a history entry (skips no-op navigations). */
const navigate = useCallback((tab: TabId, project: string | null) => {
const url = routeUrl(tab, project);
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (url === current) return;
window.history.pushState(null, "", url);
setRoute({ tab, project });
}, []);
const navigate = useCallback(
(
tab: TabId,
project: string | null,
node: string | null = null,
region: string | null = null,
) => {
const url = routeUrl(tab, project, node, region);
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (url === current) return;
window.history.pushState(null, "", url);
setRoute({ tab, project, node, region });
},
[],
);
const tabs: { id: TabId; label: string }[] = [
{ id: "graph", label: t.tabs.graph },
@@ -71,7 +101,7 @@ export function App() {
<div className="flex items-center gap-2.5">
<div className="w-[7px] h-[7px] rounded-full bg-primary" />
<span className="text-[13px] font-semibold text-foreground/90 tracking-tight">
Codebase Memory
CBM Atlas
</span>
</div>
@@ -121,7 +151,14 @@ export function App() {
{/* Content */}
<main className="flex-1 min-h-0">
{activeTab === "graph" ? (
<GraphTab project={selectedProject} />
<GraphTab
project={selectedProject}
routeNode={route.node}
routeRegion={route.region}
onRouteChange={(node, region) =>
navigate("graph", selectedProject, node, region)
}
/>
) : activeTab === "control" ? (
<ControlTab />
) : (
@@ -30,6 +30,20 @@ function mockLayoutFetch(data: GraphData) {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith("/api/layout")) {
/* The region probe answers first: a small project — no region scene. */
if (url.includes("level=regions")) {
return new Response(
JSON.stringify({
level: "regions",
method: "folders",
total_nodes: data.total_nodes,
unmapped_nodes: 0,
regions: [],
edges: [],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response(JSON.stringify(data), {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -24,6 +24,20 @@ function mockLayoutFetch(data: GraphData) {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith("/api/layout")) {
/* The region probe answers first: a small project — no region scene. */
if (url.includes("level=regions")) {
return new Response(
JSON.stringify({
level: "regions",
method: "folders",
total_nodes: data.total_nodes,
unmapped_nodes: 0,
regions: [],
edges: [],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response(JSON.stringify(data), {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -0,0 +1,148 @@
/* @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, RegionsPayload } from "../lib/types";
vi.mock("./GraphScene", () => ({
GraphScene: () => null,
computeCameraTarget: () => null,
}));
const REGIONS: RegionsPayload = {
level: "regions",
method: "leiden+folders",
total_nodes: 48000,
unmapped_nodes: 12,
regions: [
{
id: 0,
name: "src/alpha",
hub: "alpha_one",
why: "call community: 2 files, 100% under src/alpha",
files: 2,
members: 24000,
cohesion: 0.8,
top_nodes: ["alpha_one", "alpha_two"],
x: 0,
y: 0,
z: 0,
size: 60,
color: "#22aa88",
},
{
id: 1,
name: "src/beta",
hub: "beta_one",
why: "call community: 2 files, 100% under src/beta",
files: 2,
members: 24000,
cohesion: 0.7,
top_nodes: ["beta_one"],
x: 100,
y: 0,
z: 0,
size: 60,
color: "#8822aa",
},
],
edges: [{ source: 0, target: 1, weight: 4 }],
};
const SCOPED: GraphData = {
nodes: [
{
id: 7, x: 0, y: 0, z: 0, label: "Function", name: "alpha_one",
file_path: "src/alpha/a1.c", size: 1, color: "#fff",
},
],
edges: [],
total_nodes: 1,
};
function mockAtlasFetch() {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith("/api/layout")) {
if (url.includes("level=regions")) {
return new Response(JSON.stringify(REGIONS), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify(SCOPED), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("{}", { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("GraphTab region level", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("opens large projects on the region scene, not the full galaxy", async () => {
const fetchMock = mockAtlasFetch();
render(<GraphTab project="demo" />);
/* The region list is the left panel; both regions are listed. */
expect(await screen.findByText("src/alpha")).toBeInTheDocument();
expect(screen.getByText("src/beta")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Load full galaxy" })).toBeInTheDocument();
/* No full layout was fetched — only the region probe. */
const urls = fetchMock.mock.calls.map((call) => String(call[0]));
expect(urls.some((u) => u.includes("level=regions"))).toBe(true);
expect(urls.some((u) => u.includes("max_nodes"))).toBe(false);
});
it("selects a region into its panel and opens it as a scoped layout", async () => {
const fetchMock = mockAtlasFetch();
render(<GraphTab project="demo" />);
fireEvent.click(await screen.findByText("src/alpha"));
/* The region panel shows provenance and the door in. */
expect(
screen.getByText("call community: 2 files, 100% under src/alpha"),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open region →" }));
/* The scoped node arrives and the breadcrumb back to regions exists. */
expect(await screen.findByText("region: src/alpha")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "‹ Regions" })).toBeInTheDocument();
const urls = fetchMock.mock.calls.map((call) => String(call[0]));
expect(urls.some((u) => u.includes("scope=region%3A0"))).toBe(true);
});
it("restores an opened region from the URL (?region=)", async () => {
const fetchMock = mockAtlasFetch();
render(<GraphTab project="demo" routeRegion="1" />);
expect(await screen.findByText("region: src/beta")).toBeInTheDocument();
const urls = fetchMock.mock.calls.map((call) => String(call[0]));
expect(urls.some((u) => u.includes("scope=region%3A1"))).toBe(true);
});
it("reports selected nodes to the route for deep links", async () => {
mockAtlasFetch();
const onRouteChange = vi.fn();
render(
<GraphTab project="demo" routeRegion="0" onRouteChange={onRouteChange} />,
);
expect(await screen.findByText("region: src/alpha")).toBeInTheDocument();
/* Select the only node via the sidebar symbol row (#1197). */
fireEvent.click(screen.getByText("src/alpha")); /* expand the tree */
fireEvent.click(await screen.findByText("alpha_one"));
expect(onRouteChange).toHaveBeenCalledWith("7", "0");
/* The detail panel now shows the node, not a stale or empty panel. */
expect(screen.getByRole("heading", { name: "alpha_one" })).toBeInTheDocument();
});
});
+285 -10
View File
@@ -1,12 +1,15 @@
import { useEffect, useState, useCallback, useMemo } from "react";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
useGraphData,
fetchRegions,
clampNodeBudget,
GRAPH_RENDER_NODE_LIMIT,
GRAPH_NODE_BUDGET_STEP,
GRAPH_NODE_BUDGET_MAX,
} from "../hooks/useGraphData";
import { regionsToGraphData, regionsViewWorthwhile, disambiguateRegionNames } from "../lib/regions";
import { RegionPanel } from "./RegionPanel";
import { GraphLoader } from "./GraphLoader";
import { DisplaySettingsMenu } from "./DisplaySettingsMenu";
import {
@@ -25,7 +28,7 @@ import { NodeDetailPanel } from "./NodeDetailPanel";
import { MissedCallout } from "./MissedCallout";
import { ResizeHandle } from "./ResizeHandle";
import { ErrorBoundary } from "./ErrorBoundary";
import type { GraphNode, GraphData, RepoInfo } from "../lib/types";
import type { GraphNode, GraphData, Region, RegionsPayload, RepoInfo } from "../lib/types";
import { colorForStatus } from "../lib/colors";
/* Persist panel widths */
@@ -57,15 +60,38 @@ function saveNodeBudget(project: string, value: number) {
interface GraphTabProps {
project: string | null;
/* Deep-link state from the URL (?node=&region=) and the reporter back. */
routeNode?: string | null;
routeRegion?: string | null;
onRouteChange?: (node: string | null, region: string | null) => void;
}
/* The galaxy's level-of-detail state: the region scene (coarsest), one
* opened region (full detail, scoped), or the classic full galaxy. */
type AtlasView =
| { kind: "deciding" }
| { kind: "regions" }
| { kind: "region"; region: Region }
| { kind: "full" };
export function formatGraphLimitNotice(data: GraphData | null): string | null {
if (!data || data.total_nodes <= data.nodes.length) return null;
return `Showing ${data.nodes.length.toLocaleString("en-US")} of ${data.total_nodes.toLocaleString("en-US")} nodes (${data.edges.length.toLocaleString("en-US")} edges). Raise the node budget or use filters.`;
}
export function GraphTab({ project }: GraphTabProps) {
export function GraphTab({
project,
routeNode = null,
routeRegion = null,
onRouteChange,
}: GraphTabProps) {
const { data, loading, error, progress, fetchOverview } = useGraphData();
const [view, setView] = useState<AtlasView>({ kind: "deciding" });
const viewRef = useRef<AtlasView>(view);
viewRef.current = view;
const [regionsPayload, setRegionsPayload] = useState<RegionsPayload | null>(null);
const [regionsError, setRegionsError] = useState<string | null>(null);
const [selectedRegion, setSelectedRegion] = useState<Region | null>(null);
const [highlightedIds, setHighlightedIds] = useState<Set<number> | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
@@ -188,15 +214,66 @@ export function GraphTab({ project }: GraphTabProps) {
}
}, [project]);
/* …and fetch only once budget and project agree (one fetch per change). */
/* …and decide the level of detail once budget and project agree. The
* region scene is the default above REGIONS_MIN_TOTAL_NODES; small
* projects load the full galaxy directly. An open region survives budget
* changes (the ref carries the current view across this effect). */
useEffect(() => {
if (project && budget.project === project) {
fetchOverview(project, budget.value);
setHighlightedIds(null);
setSelectedPath(null);
if (!project || budget.project !== project) return;
let cancelled = false;
setHighlightedIds(null);
setSelectedPath(null);
const current = viewRef.current;
if (current.kind === "region") {
fetchOverview(project, budget.value, "code", `region:${current.region.id}`);
return;
}
if (current.kind === "full") {
fetchOverview(project, budget.value);
return;
}
setView({ kind: "deciding" });
setRegionsError(null);
(async () => {
try {
const payload = await fetchRegions(project);
if (cancelled) return;
setRegionsPayload(payload);
const restored = routeRegion
? payload.regions.find((r) => String(r.id) === routeRegion)
: null;
if (restored) {
setView({ kind: "region", region: restored });
fetchOverview(project, budget.value, "code", `region:${restored.id}`);
} else if (regionsViewWorthwhile(payload)) {
setView({ kind: "regions" });
} else {
setView({ kind: "full" });
fetchOverview(project, budget.value);
}
} catch (e) {
if (cancelled) return;
/* Older servers have no region level — fall back to the galaxy. */
setRegionsError(e instanceof Error ? e.message : "regions unavailable");
setView({ kind: "full" });
fetchOverview(project, budget.value);
}
})();
return () => {
cancelled = true;
};
/* routeRegion is a first-load restore input, not a live dependency. */
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project, budget, fetchOverview]);
/* Deep link: once data is loaded, restore the node the URL names. */
useEffect(() => {
if (!routeNode || !data || selectedNode) return;
const target = data.nodes.find((n) => String(n.id) === routeNode);
if (target) handleNodeClickRef.current?.(target);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeNode, data]);
/* Missed skeleton: offset into place and paint white — a ghost of the
* files the graph could not fully cover, sitting beside the galaxy. */
const missedSkeleton = useMemo(() => {
@@ -267,6 +344,7 @@ export function GraphTab({ project }: GraphTabProps) {
}
setSelectedPath(path);
setHighlightedIds(nodeIds);
setSelectedNode(null); /* #1197: never leave a stale node in the panel */
setCameraTarget(computeCameraTarget(filteredData.nodes, nodeIds));
},
[filteredData],
@@ -299,9 +377,55 @@ export function GraphTab({ project }: GraphTabProps) {
setHighlightedIds(connectedIds);
setSelectedPath(node.file_path ?? null);
setCameraTarget(computeCameraTarget(filteredData.nodes, connectedIds));
onRouteChange?.(
String(node.id),
viewRef.current.kind === "region" ? String(viewRef.current.region.id) : null,
);
},
[filteredData, missedSkeleton],
[filteredData, missedSkeleton, onRouteChange],
);
const handleNodeClickRef = useRef<typeof handleNodeClick | null>(null);
handleNodeClickRef.current = handleNodeClick;
/* Region scene interactions. */
const regionGraph = useMemo(
() => (regionsPayload ? regionsToGraphData(regionsPayload) : null),
[regionsPayload],
);
const displayRegions = useMemo(
() => (regionsPayload ? disambiguateRegionNames(regionsPayload.regions) : []),
[regionsPayload],
);
const openRegion = useCallback(
(region: Region) => {
if (!project) return;
setSelectedRegion(null);
setSelectedNode(null);
setHighlightedIds(null);
setSelectedPath(null);
setCameraTarget(null);
setView({ kind: "region", region });
fetchOverview(project, budget.value, "code", `region:${region.id}`);
onRouteChange?.(null, String(region.id));
},
[project, budget.value, fetchOverview, onRouteChange],
);
const backToRegions = useCallback(() => {
setSelectedRegion(null);
setSelectedNode(null);
setHighlightedIds(null);
setSelectedPath(null);
setCameraTarget(null);
setView({ kind: "regions" });
onRouteChange?.(null, null);
}, [onRouteChange]);
const loadFullGalaxy = useCallback(() => {
if (!project) return;
setSelectedRegion(null);
setView({ kind: "full" });
fetchOverview(project, budget.value);
onRouteChange?.(null, null);
}, [project, budget.value, fetchOverview, onRouteChange]);
const handleNavigateToNode = useCallback(
(node: GraphNode) => {
@@ -356,6 +480,136 @@ export function GraphTab({ project }: GraphTabProps) {
);
}
if (view.kind === "deciding") {
return (
<div className="flex items-center justify-center h-full">
<p className="text-white/30 text-sm">Mapping regions…</p>
</div>
);
}
/* ── Region scene: the coarsest level of detail ──────────────── */
if (view.kind === "regions" && regionGraph) {
const selected = selectedRegion;
return (
<div className="h-full flex">
<div
className="border-r border-border/30 flex flex-col h-full bg-[#0b1920]/90 backdrop-blur-md shrink-0"
style={{ width: leftWidth }}
>
<div className="px-4 pt-3 pb-2 shrink-0">
<span className="text-[11px] font-medium text-foreground/50 uppercase tracking-widest">
Regions
</span>
<p className="text-[10px] text-foreground/25 mt-1">
{regionsPayload?.method === "leiden+folders"
? "call communities + folder groups"
: "folder groups"}
</p>
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-1">
{displayRegions.map((region) => (
<button
key={region.id}
onClick={() => setSelectedRegion(region)}
onDoubleClick={() => openRegion(region)}
className={`flex items-center gap-2 w-full text-left px-4 py-[5px] text-[12px] transition-colors ${
selected?.id === region.id
? "bg-primary/10 text-primary"
: "text-foreground/60 hover:text-foreground/80 hover:bg-white/[0.03]"
}`}
>
<span
className="w-[7px] h-[7px] rounded-full shrink-0"
style={{ backgroundColor: region.color }}
/>
<span className="truncate">{region.name}</span>
<span className="text-foreground/15 ml-auto text-[10px] tabular-nums shrink-0">
{region.members.toLocaleString("en-US")}
</span>
</button>
))}
</div>
</div>
<ResizeHandle
side="left"
onResize={(d) => {
setLeftWidth((w) => {
const nw = Math.max(150, Math.min(500, w + d));
saveWidth("cbm-left-w", nw);
return nw;
});
}}
/>
<div className="flex-1 relative overflow-hidden">
<ErrorBoundary>
<GraphScene
data={regionGraph}
missed={null}
highlightedIds={selected ? new Set([selected.id]) : null}
cameraTarget={cameraTarget}
showLabels={true}
display={display}
onNodeClick={(node) => {
const region = displayRegions.find((r) => r.id === node.id);
if (region) {
setSelectedRegion(region);
setCameraTarget(
computeCameraTarget(regionGraph.nodes, new Set([node.id])),
);
}
}}
onBackgroundClick={() => setSelectedRegion(null)}
/>
</ErrorBoundary>
<div className="absolute top-4 left-4 text-[11px] text-white/30 pointer-events-none font-mono">
<p>
{displayRegions.length} regions /{" "}
{regionsPayload?.total_nodes.toLocaleString("en-US")} nodes
</p>
<p className="text-white/20 mt-0.5">
double-click a region (or use its panel) to open it
</p>
</div>
<div className="absolute top-4 right-4 flex gap-2 items-center">
<Button variant="outline" size="sm" onClick={loadFullGalaxy}>
Load full galaxy
</Button>
<DisplaySettingsMenu settings={display} onChange={updateDisplay} />
</div>
</div>
{selected && (
<>
<ResizeHandle
side="right"
onResize={(d) => {
setRightWidth((w) => {
const nw = Math.max(200, Math.min(500, w + d));
saveWidth("cbm-right-w", nw);
return nw;
});
}}
/>
<div
className="border-l border-border shrink-0 h-full overflow-hidden"
style={{ width: rightWidth, maxHeight: "100%" }}
>
<RegionPanel
region={selected}
onOpen={openRegion}
onClose={() => setSelectedRegion(null)}
/>
</div>
</>
)}
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center h-full">
@@ -420,6 +674,7 @@ export function GraphTab({ project }: GraphTabProps) {
<Sidebar
nodes={filteredData.nodes}
onSelectPath={handleSelectPath}
onSelectNode={handleNodeClick}
selectedPath={selectedPath}
/>
</div>
@@ -462,6 +717,12 @@ export function GraphTab({ project }: GraphTabProps) {
{/* HUD */}
<div className="absolute top-4 left-4 text-[11px] text-white/30 pointer-events-none font-mono">
{regionsError && (
<p className="text-amber-300/60">regions unavailable: {regionsError}</p>
)}
{view.kind === "region" && (
<p className="text-primary/60">region: {view.region.name}</p>
)}
<p>
{filteredData.nodes.length.toLocaleString()} nodes /{" "}
{filteredData.edges.length.toLocaleString()} edges
@@ -482,6 +743,11 @@ export function GraphTab({ project }: GraphTabProps) {
</div>
<div className="absolute top-4 right-4 flex gap-2 items-center">
{view.kind === "region" && (
<Button variant="outline" size="sm" onClick={backToRegions}>
‹ Regions
</Button>
)}
{highlightedIds && (
<Button
size="sm"
@@ -530,7 +796,12 @@ export function GraphTab({ project }: GraphTabProps) {
setSelectedPath(null);
setSelectedNode(null);
setCameraTarget(null);
fetchOverview(project, budget.value);
fetchOverview(
project,
budget.value,
"code",
view.kind === "region" ? `region:${view.region.id}` : undefined,
);
}}
>
Refresh
@@ -581,6 +852,10 @@ export function GraphTab({ project }: GraphTabProps) {
setSelectedNode(null);
setHighlightedIds(null);
setSelectedPath(null);
onRouteChange?.(
null,
view.kind === "region" ? String(view.region.id) : null,
);
}}
onNavigate={handleNavigateToNode}
/>
@@ -0,0 +1,45 @@
/* @vitest-environment jsdom */
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { NodeDetailPanel, CONNECTIONS_PAGE_SIZE } from "./NodeDetailPanel";
import type { GraphEdge, GraphNode } from "../lib/types";
/* #1198: a node with more connections than one page must offer them all —
* paged behind "Show more", never silently cut at 25. */
describe("NodeDetailPanel connections paging", () => {
it("pages beyond the first slice instead of capping at 25", () => {
const total = CONNECTIONS_PAGE_SIZE + 5;
const center: GraphNode = {
id: 0, x: 0, y: 0, z: 0, label: "Function", name: "hub", size: 1, color: "#fff",
};
const nodes: GraphNode[] = [center];
const edges: GraphEdge[] = [];
for (let i = 1; i <= total; i++) {
nodes.push({
id: i, x: 0, y: 0, z: 0, label: "Function", name: `callee_${i}`, size: 1, color: "#fff",
});
edges.push({ source: 0, target: i, type: "CALLS" });
}
render(
<NodeDetailPanel
node={center}
allNodes={nodes}
allEdges={edges}
project="demo"
repoInfo={null}
onClose={vi.fn()}
onNavigate={vi.fn()}
/>,
);
/* First page rendered, the rest offered. */
expect(screen.getByText(`callee_${CONNECTIONS_PAGE_SIZE}`)).toBeInTheDocument();
expect(screen.queryByText(`callee_${total}`)).not.toBeInTheDocument();
const more = screen.getByRole("button", { name: /Show 5 more/ });
fireEvent.click(more);
expect(screen.getByText(`callee_${total}`)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Show .* more/ })).not.toBeInTheDocument();
});
});
+42 -22
View File
@@ -1,4 +1,7 @@
import { useMemo, useState, useEffect } from "react";
/* #1198: connections render in slices the user grows — never a silent cap. */
export const CONNECTIONS_PAGE_SIZE = 25;
import { ScrollArea } from "@/components/ui/scroll-area";
import { colorForLabel } from "../lib/colors";
import { callTool } from "../api/rpc";
@@ -206,6 +209,44 @@ export function NodeDetailPanel({
);
}
function ConnectionGroup({ type, conns, icon, onNavigate }: {
type: string; conns: Connection[]; icon: string;
onNavigate: (n: GraphNode) => void;
}) {
const [visible, setVisible] = useState(CONNECTIONS_PAGE_SIZE);
useEffect(() => setVisible(CONNECTIONS_PAGE_SIZE), [type, conns.length]);
const remaining = conns.length - visible;
return (
<div className="mb-2">
<p className="text-[9px] text-foreground/20 uppercase tracking-wider mb-1 font-medium">
{type.replace(/_/g, " ").toLowerCase()}
</p>
<div className="space-y-px">
{conns.slice(0, visible).map((c, i) => (
<button
key={`${c.node.id}-${i}`}
onClick={() => onNavigate(c.node)}
className="flex items-center gap-1.5 w-full text-left px-2 py-[4px] rounded-md hover:bg-white/[0.04] text-[11px] transition-colors group"
>
<span className="text-foreground/15 text-[10px] group-hover:text-foreground/30">{icon}</span>
<span className="w-[5px] h-[5px] rounded-full shrink-0" style={{ backgroundColor: colorForLabel(c.node.label) }} />
<span className="text-foreground/55 group-hover:text-foreground/80 truncate">{c.node.name}</span>
<span className="text-foreground/10 ml-auto text-[10px] shrink-0">{c.node.label}</span>
</button>
))}
{remaining > 0 && (
<button
onClick={() => setVisible((v) => v + 200)}
className="w-full text-left px-2 py-1 text-[10px] text-primary/70 hover:text-primary transition-colors"
>
Show {Math.min(remaining, 200)} more ({remaining} hidden)
</button>
)}
</div>
</div>
);
}
function ConnectionSection({ title, count, icon, groups, onNavigate }: {
title: string; count: number; icon: string;
groups: [string, Connection[]][];
@@ -217,28 +258,7 @@ function ConnectionSection({ title, count, icon, groups, onNavigate }: {
{title} <span className="text-foreground/15">({count})</span>
</p>
{groups.map(([type, conns]) => (
<div key={type} className="mb-2">
<p className="text-[9px] text-foreground/20 uppercase tracking-wider mb-1 font-medium">
{type.replace(/_/g, " ").toLowerCase()}
</p>
<div className="space-y-px">
{conns.slice(0, 25).map((c, i) => (
<button
key={`${c.node.id}-${i}`}
onClick={() => onNavigate(c.node)}
className="flex items-center gap-1.5 w-full text-left px-2 py-[4px] rounded-md hover:bg-white/[0.04] text-[11px] transition-colors group"
>
<span className="text-foreground/15 text-[10px] group-hover:text-foreground/30">{icon}</span>
<span className="w-[5px] h-[5px] rounded-full shrink-0" style={{ backgroundColor: colorForLabel(c.node.label) }} />
<span className="text-foreground/55 group-hover:text-foreground/80 truncate">{c.node.name}</span>
<span className="text-foreground/10 ml-auto text-[10px] shrink-0">{c.node.label}</span>
</button>
))}
{conns.length > 25 && (
<p className="text-[10px] text-foreground/15 px-2 py-1">+{conns.length - 25} more</p>
)}
</div>
</div>
<ConnectionGroup key={type} type={type} conns={conns} icon={icon} onNavigate={onNavigate} />
))}
</div>
);
+94
View File
@@ -0,0 +1,94 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import type { Region } from "../lib/types";
interface RegionPanelProps {
region: Region;
onOpen: (region: Region) => void;
onClose: () => void;
}
/* Detail panel for a selected region body: what it is, why it got its name,
* its representative symbols, and the door into its full-detail layout. */
export function RegionPanel({ region, onOpen, onClose }: RegionPanelProps) {
return (
<div className="w-full bg-[#0b1920]/95 backdrop-blur-xl flex flex-col h-full min-h-0 overflow-hidden">
<div className="px-4 pt-4 pb-3 border-b border-border/30">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1.5">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: region.color }}
/>
<h3 className="text-[13px] font-semibold text-foreground truncate">
{region.name}
</h3>
</div>
<span className="inline-block px-2 py-0.5 rounded-md text-[10px] font-medium bg-white/[0.06] text-foreground/60">
Region
</span>
</div>
<button
onClick={onClose}
className="text-foreground/20 hover:text-foreground/50 transition-colors text-[16px] leading-none p-1"
>
×
</button>
</div>
{region.why && (
<p className="text-[11px] text-foreground/40 mt-2 leading-relaxed">
{region.why}
</p>
)}
<button
onClick={() => onOpen(region)}
className="mt-3 px-2.5 py-1 rounded-md bg-primary/15 text-primary text-[11px] font-medium hover:bg-primary/25 transition-colors"
>
Open region →
</button>
<div className="flex gap-5 mt-3">
{[
{ label: "Symbols", value: region.members.toLocaleString("en-US") },
{ label: "Files", value: region.files.toLocaleString("en-US") },
{ label: "Cohesion", value: region.cohesion.toFixed(2) },
].map((stat) => (
<div key={stat.label}>
<p className="text-[9px] text-foreground/25 uppercase tracking-widest">
{stat.label}
</p>
<p className="text-[16px] font-semibold tabular-nums text-foreground">
{stat.value}
</p>
</div>
))}
</div>
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="px-4 py-3">
<p className="text-[11px] font-medium text-foreground/40 mb-2">
Most connected
</p>
<div className="space-y-px">
{region.top_nodes.map((name) => (
<p
key={name}
className="px-2 py-[4px] text-[11px] font-mono text-foreground/55 truncate"
>
{name}
</p>
))}
{region.top_nodes.length === 0 && (
<p className="text-[12px] text-foreground/20 py-4 text-center">
No callable members
</p>
)}
</div>
</div>
</ScrollArea>
</div>
);
}
+10 -6
View File
@@ -6,6 +6,9 @@ import { useUiMessages } from "../lib/i18n";
interface SidebarProps {
nodes: GraphNode[];
onSelectPath: (path: string, nodeIds: Set<number>) => void;
/* #1197: clicking a symbol row selects the node itself — highlight in the
* scene AND the detail panel, exactly like clicking its 3D point. */
onSelectNode: (node: GraphNode) => void;
selectedPath: string | null;
}
@@ -59,9 +62,10 @@ function flattenSingleChild(dir: DirNode): DirNode {
return { ...dir, children };
}
function TreeItem({ dir, depth, onSelect, selectedPath }: {
function TreeItem({ dir, depth, onSelect, onSelectNode, selectedPath }: {
dir: DirNode; depth: number;
onSelect: (path: string, ids: Set<number>) => void;
onSelectNode: (node: GraphNode) => void;
selectedPath: string | null;
}) {
const [expanded, setExpanded] = useState(false);
@@ -86,11 +90,11 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: {
</button>
{expanded && (
<>
{sorted.map((c) => <TreeItem key={c.fullPath} dir={c} depth={depth+1} onSelect={onSelect} selectedPath={selectedPath} />)}
{sorted.map((c) => <TreeItem key={c.fullPath} dir={c} depth={depth+1} onSelect={onSelect} onSelectNode={onSelectNode} selectedPath={selectedPath} />)}
{sortedNodes.map((gn) => (
<button
key={gn.id}
onClick={() => onSelect(dir.fullPath + "/" + gn.name, new Set([gn.id]))}
onClick={() => onSelectNode(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` }}
>
@@ -105,7 +109,7 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: {
);
}
export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
export function Sidebar({ nodes, onSelectPath, onSelectNode, selectedPath }: SidebarProps) {
const t = useUiMessages();
const [search, setSearch] = useState("");
const tree = useMemo(() => flattenSingleChild(buildFileTree(nodes)), [nodes]);
@@ -148,7 +152,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={() => onSelectNode(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 }} />
@@ -158,7 +162,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
))
)
) : (
topLevel.map((c) => <TreeItem key={c.fullPath} dir={c} depth={0} onSelect={onSelectPath} selectedPath={selectedPath} />)
topLevel.map((c) => <TreeItem key={c.fullPath} dir={c} depth={0} onSelect={onSelectPath} onSelectNode={onSelectNode} selectedPath={selectedPath} />)
)}
</div>
</ScrollArea>
+22 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import type { GraphData } from "../lib/types";
import type { GraphData, RegionsPayload } from "../lib/types";
export interface LoadProgress {
receivedBytes: number;
@@ -15,6 +15,7 @@ interface UseGraphDataResult {
project: string,
maxNodes?: number,
graph?: "code" | "missed",
scope?: string,
) => void;
fetchDetail: (project: string, centerNode: string) => void;
}
@@ -45,9 +46,11 @@ export async function fetchLayout(
maxNodes = GRAPH_RENDER_NODE_LIMIT,
onProgress?: (progress: LoadProgress) => void,
graph: GraphVariant = "code",
scope?: string,
): Promise<GraphData> {
const params = new URLSearchParams({ project, max_nodes: String(maxNodes) });
if (graph === "missed") params.set("graph", "missed");
if (scope) params.set("scope", scope);
const res = await fetch(`/api/layout?${params}`);
if (!res.ok) {
@@ -93,12 +96,17 @@ export function useGraphData(): UseGraphDataResult {
const [progress, setProgress] = useState<LoadProgress>(NO_PROGRESS);
const fetchOverview = useCallback(
async (project: string, maxNodes?: number, graph: GraphVariant = "code") => {
async (
project: string,
maxNodes?: number,
graph: GraphVariant = "code",
scope?: string,
) => {
setLoading(true);
setError(null);
setProgress(NO_PROGRESS);
try {
const result = await fetchLayout(project, maxNodes, setProgress, graph);
const result = await fetchLayout(project, maxNodes, setProgress, graph, scope);
setData(result);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to fetch layout");
@@ -129,3 +137,14 @@ export function useGraphData(): UseGraphDataResult {
return { data, loading, error, progress, fetchOverview, fetchDetail };
}
/* Region level of detail: the coarsest scene — one body per region. */
export async function fetchRegions(project: string): Promise<RegionsPayload> {
const params = new URLSearchParams({ project, level: "regions" });
const res = await fetch(`/api/layout?${params}`);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return res.json();
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
disambiguateRegionNames,
regionsToGraphData,
regionsViewWorthwhile,
REGIONS_MIN_TOTAL_NODES,
} from "./regions";
import type { Region, RegionsPayload } from "./types";
function region(partial: Partial<Region> & { id: number; name: string }): Region {
return {
files: 2,
members: 10,
cohesion: 0.5,
top_nodes: ["a"],
x: 0,
y: 0,
z: 0,
size: 20,
color: "#123456",
...partial,
};
}
function payload(regions: Region[], total = 10000): RegionsPayload {
return {
level: "regions",
method: "leiden+folders",
total_nodes: total,
unmapped_nodes: 0,
regions,
edges: [{ source: 0, target: 1, weight: 3 }],
};
}
describe("region adapters", () => {
it("appends the hub to duplicate display names", () => {
const out = disambiguateRegionNames([
region({ id: 0, name: "django/db", hub: "join" }),
region({ id: 1, name: "django/db", hub: "get_field" }),
region({ id: 2, name: "django/core", hub: "call_command" }),
]);
expect(out.map((r) => r.name)).toEqual([
"django/db · join",
"django/db · get_field",
"django/core",
]);
});
it("adapts regions to scene nodes and REGION edges", () => {
const data = regionsToGraphData(
payload([region({ id: 0, name: "a" }), region({ id: 1, name: "b" })]),
);
expect(data.nodes).toHaveLength(2);
expect(data.nodes[0].label).toBe("Region");
expect(data.edges).toEqual([{ source: 0, target: 1, type: "REGION" }]);
expect(data.total_nodes).toBe(10000);
});
it("skips the region scene for small or single-region projects", () => {
const two = [region({ id: 0, name: "a" }), region({ id: 1, name: "b" })];
expect(regionsViewWorthwhile(payload(two, REGIONS_MIN_TOTAL_NODES))).toBe(false);
expect(regionsViewWorthwhile(payload(two, REGIONS_MIN_TOTAL_NODES + 1))).toBe(true);
expect(
regionsViewWorthwhile(payload([region({ id: 0, name: "a" })], 99999)),
).toBe(false);
});
});
+59
View File
@@ -0,0 +1,59 @@
/* Region level of detail — adapters between the level=regions payload
* (layout_regions.c) and the node/edge shapes the 3D scene renders. */
import type { GraphData, GraphNode, Region, RegionsPayload } from "./types";
/* The scene renders GraphNodes; a region becomes one body. Region ids live in
* a separate id space from node ids — the scene never mixes the two levels,
* so collisions are impossible by construction. */
export function regionToNode(region: Region): GraphNode {
return {
id: region.id,
x: region.x,
y: region.y,
z: region.z,
label: "Region",
name: region.name,
size: region.size,
color: region.color,
status: "structural",
in_calls: 0,
};
}
/* Duplicate display names (two Leiden communities dominated by the same
* folder) get their hub appended so the human can tell them apart. */
export function disambiguateRegionNames(regions: Region[]): Region[] {
const counts = new Map<string, number>();
for (const region of regions) {
counts.set(region.name, (counts.get(region.name) ?? 0) + 1);
}
return regions.map((region) =>
(counts.get(region.name) ?? 0) > 1 && region.hub
? { ...region, name: `${region.name} · ${region.hub}` }
: region,
);
}
/* GraphData for the scene: bodies + weighted arcs (type "REGION"). */
export function regionsToGraphData(payload: RegionsPayload): GraphData {
const regions = disambiguateRegionNames(payload.regions);
return {
nodes: regions.map(regionToNode),
edges: payload.edges.map((edge) => ({
source: edge.source,
target: edge.target,
type: "REGION",
})),
total_nodes: payload.total_nodes,
};
}
/* Below this project size the region level adds a hop without teaching
* anything — the full galaxy is already readable and loads fast. */
export const REGIONS_MIN_TOTAL_NODES = 5000;
export function regionsViewWorthwhile(payload: RegionsPayload): boolean {
return (
payload.total_nodes > REGIONS_MIN_TOTAL_NODES && payload.regions.length > 1
);
}
+33
View File
@@ -67,6 +67,39 @@ export interface GraphData {
missed_graph?: MissedGraph;
}
/* Region level of detail (level=regions): one body per region — Leiden call
* communities voted per file, folder groups as fallback (layout_regions.c). */
export interface Region {
id: number;
name: string;
hub?: string;
why?: string;
files: number;
members: number;
cohesion: number;
top_nodes: string[];
x: number;
y: number;
z: number;
size: number;
color: string;
}
export interface RegionEdge {
source: number;
target: number;
weight: number;
}
export interface RegionsPayload {
level: "regions";
method: string;
total_nodes: number;
unmapped_nodes: number;
regions: Region[];
edges: RegionEdge[];
}
export interface Project {
name: string;
root_path: string;
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/displaysettingsmenu.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphloader.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.deadcode.test.tsx","./src/components/graphtab.filters.test.tsx","./src/components/graphtab.regions.test.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/missedcallout.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.showmore.test.tsx","./src/components/nodedetailpanel.test.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/regionpanel.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/regions.test.ts","./src/lib/regions.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}