diff --git a/graph-ui/index.html b/graph-ui/index.html index e20a6503b..a1cf9a24c 100644 --- a/graph-ui/index.html +++ b/graph-ui/index.html @@ -3,7 +3,7 @@ - Codebase Memory — Graph + CBM Atlas
diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 18392667c..2cd1b72ee 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -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() {
- Codebase Memory + CBM Atlas
@@ -121,7 +151,14 @@ export function App() { {/* Content */}
{activeTab === "graph" ? ( - + + navigate("graph", selectedProject, node, region) + } + /> ) : activeTab === "control" ? ( ) : ( diff --git a/graph-ui/src/components/GraphTab.deadcode.test.tsx b/graph-ui/src/components/GraphTab.deadcode.test.tsx index 43f880f30..285600188 100644 --- a/graph-ui/src/components/GraphTab.deadcode.test.tsx +++ b/graph-ui/src/components/GraphTab.deadcode.test.tsx @@ -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" }, diff --git a/graph-ui/src/components/GraphTab.filters.test.tsx b/graph-ui/src/components/GraphTab.filters.test.tsx index 9ee22f210..bba467595 100644 --- a/graph-ui/src/components/GraphTab.filters.test.tsx +++ b/graph-ui/src/components/GraphTab.filters.test.tsx @@ -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" }, diff --git a/graph-ui/src/components/GraphTab.regions.test.tsx b/graph-ui/src/components/GraphTab.regions.test.tsx new file mode 100644 index 000000000..ae21bb108 --- /dev/null +++ b/graph-ui/src/components/GraphTab.regions.test.tsx @@ -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(); + + /* 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(); + + 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(); + + 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( + , + ); + 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(); + }); +}); diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index bc05d5c14..ea6e96694 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -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({ kind: "deciding" }); + const viewRef = useRef(view); + viewRef.current = view; + const [regionsPayload, setRegionsPayload] = useState(null); + const [regionsError, setRegionsError] = useState(null); + const [selectedRegion, setSelectedRegion] = useState(null); const [highlightedIds, setHighlightedIds] = useState | null>(null); const [selectedPath, setSelectedPath] = useState(null); const [selectedNode, setSelectedNode] = useState(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(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 ( +
+

Mapping regions…

+
+ ); + } + + /* ── Region scene: the coarsest level of detail ──────────────── */ + if (view.kind === "regions" && regionGraph) { + const selected = selectedRegion; + return ( +
+
+
+ + Regions + +

+ {regionsPayload?.method === "leiden+folders" + ? "call communities + folder groups" + : "folder groups"} +

+
+
+ {displayRegions.map((region) => ( + + ))} +
+
+ { + setLeftWidth((w) => { + const nw = Math.max(150, Math.min(500, w + d)); + saveWidth("cbm-left-w", nw); + return nw; + }); + }} + /> + +
+ + { + const region = displayRegions.find((r) => r.id === node.id); + if (region) { + setSelectedRegion(region); + setCameraTarget( + computeCameraTarget(regionGraph.nodes, new Set([node.id])), + ); + } + }} + onBackgroundClick={() => setSelectedRegion(null)} + /> + + +
+

+ {displayRegions.length} regions /{" "} + {regionsPayload?.total_nodes.toLocaleString("en-US")} nodes +

+

+ double-click a region (or use its panel) to open it +

+
+ +
+ + +
+
+ + {selected && ( + <> + { + setRightWidth((w) => { + const nw = Math.max(200, Math.min(500, w + d)); + saveWidth("cbm-right-w", nw); + return nw; + }); + }} + /> +
+ setSelectedRegion(null)} + /> +
+ + )} +
+ ); + } + if (loading) { return (
@@ -420,6 +674,7 @@ export function GraphTab({ project }: GraphTabProps) {
@@ -462,6 +717,12 @@ export function GraphTab({ project }: GraphTabProps) { {/* HUD */}
+ {regionsError && ( +

regions unavailable: {regionsError}

+ )} + {view.kind === "region" && ( +

region: {view.region.name}

+ )}

{filteredData.nodes.length.toLocaleString()} nodes /{" "} {filteredData.edges.length.toLocaleString()} edges @@ -482,6 +743,11 @@ export function GraphTab({ project }: GraphTabProps) {

+ {view.kind === "region" && ( + + )} {highlightedIds && ( + ))} + {remaining > 0 && ( + + )} +
+
+ ); +} + 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} ({count})

{groups.map(([type, conns]) => ( -
-

- {type.replace(/_/g, " ").toLowerCase()} -

-
- {conns.slice(0, 25).map((c, i) => ( - - ))} - {conns.length > 25 && ( -

+{conns.length - 25} more

- )} -
-
+ ))} ); diff --git a/graph-ui/src/components/RegionPanel.tsx b/graph-ui/src/components/RegionPanel.tsx new file mode 100644 index 000000000..69bb0a886 --- /dev/null +++ b/graph-ui/src/components/RegionPanel.tsx @@ -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 ( +
+
+
+
+
+ +

+ {region.name} +

+
+ + Region + +
+ +
+ + {region.why && ( +

+ {region.why} +

+ )} + + + +
+ {[ + { 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) => ( +
+

+ {stat.label} +

+

+ {stat.value} +

+
+ ))} +
+
+ + +
+

+ Most connected +

+
+ {region.top_nodes.map((name) => ( +

+ {name} +

+ ))} + {region.top_nodes.length === 0 && ( +

+ No callable members +

+ )} +
+
+
+
+ ); +} diff --git a/graph-ui/src/components/Sidebar.tsx b/graph-ui/src/components/Sidebar.tsx index 0343940a4..52a76a8ae 100644 --- a/graph-ui/src/components/Sidebar.tsx +++ b/graph-ui/src/components/Sidebar.tsx @@ -6,6 +6,9 @@ import { useUiMessages } from "../lib/i18n"; interface SidebarProps { nodes: GraphNode[]; onSelectPath: (path: string, nodeIds: Set) => 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) => void; + onSelectNode: (node: GraphNode) => void; selectedPath: string | null; }) { const [expanded, setExpanded] = useState(false); @@ -86,11 +90,11 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: { {expanded && ( <> - {sorted.map((c) => )} + {sorted.map((c) => )} {sortedNodes.map((gn) => (