mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-09 09:11:00 +08:00
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:
+1
-1
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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=®ion=) 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user