diff --git a/.gitignore b/.gitignore index 5860fb1b6..d9b8639ad 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,9 @@ build/ node_modules/ graph-ui/dist/ +# Generated by scripts/embed-frontend.sh during the --with-ui build +src/ui/embedded_assets.c + # Generated reports BENCHMARK_REPORT.md TEST_PLAN.md diff --git a/Makefile.cbm b/Makefile.cbm index 2bcf7b4d7..c96068590 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -89,9 +89,16 @@ ifeq ($(STATIC),1) STATIC_FLAGS := -static endif -LDFLAGS = -lm -lstdc++ -lpthread -lz $(LIBGIT2_LIBS) $(WIN32_LIBS) $(STATIC_FLAGS) -LDFLAGS_TEST = -lm -lstdc++ -lpthread -lz $(SANITIZE) $(LIBGIT2_LIBS) $(WIN32_LIBS) -LDFLAGS_TSAN = -lm -lstdc++ -lpthread -lz -fsanitize=thread $(LIBGIT2_LIBS) $(WIN32_LIBS) +# libdl: SQLite's dlopen/dlsym live in a separate libdl on glibc < 2.34. +# Linked on non-Windows (harmless stub on glibc >= 2.34 and macOS); MinGW has no libdl. +DL_LIBS := +ifneq ($(IS_MINGW),yes) +DL_LIBS := -ldl +endif + +LDFLAGS = -lm -lstdc++ -lpthread -lz $(DL_LIBS) $(LIBGIT2_LIBS) $(WIN32_LIBS) $(STATIC_FLAGS) +LDFLAGS_TEST = -lm -lstdc++ -lpthread -lz $(DL_LIBS) $(SANITIZE) $(LIBGIT2_LIBS) $(WIN32_LIBS) +LDFLAGS_TSAN = -lm -lstdc++ -lpthread -lz $(DL_LIBS) -fsanitize=thread $(LIBGIT2_LIBS) $(WIN32_LIBS) # ── Source files ───────────────────────────────────────────────── diff --git a/graph-ui/index.html b/graph-ui/index.html index e20a6503b..4c2cb6ad9 100644 --- a/graph-ui/index.html +++ b/graph-ui/index.html @@ -3,7 +3,7 @@ - Codebase Memory — Graph + Codebase Memory - Graph
diff --git a/graph-ui/package-lock.json b/graph-ui/package-lock.json index 000a7f0ab..da1847a2a 100644 --- a/graph-ui/package-lock.json +++ b/graph-ui/package-lock.json @@ -22,6 +22,8 @@ "three": "~0.183.0" }, "devDependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", "@tailwindcss/vite": "^4.2.1", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.1.0", @@ -956,6 +958,26 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/graph-ui/package.json b/graph-ui/package.json index c26679992..35b12aa52 100644 --- a/graph-ui/package.json +++ b/graph-ui/package.json @@ -26,6 +26,8 @@ "three": "~0.183.0" }, "devDependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", "@tailwindcss/vite": "^4.2.1", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.1.0", diff --git a/graph-ui/public/fonts/DejaVu-LICENSE.txt b/graph-ui/public/fonts/DejaVu-LICENSE.txt new file mode 100644 index 000000000..746d06306 --- /dev/null +++ b/graph-ui/public/fonts/DejaVu-LICENSE.txt @@ -0,0 +1,78 @@ +Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: DejaVu fonts +Upstream-Author: Stepan Roh (original author), + see /usr/share/doc/ttf-dejavu/AUTHORS for full list +Source: http://dejavu-fonts.org/ + +Files: * +Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. + Bitstream Vera is a trademark of Bitstream, Inc. + DejaVu changes are in public domain. +License: bitstream-vera + Permission is hereby granted, free of charge, to any person obtaining a copy + of the fonts accompanying this license ("Fonts") and associated + documentation files (the "Font Software"), to reproduce and distribute the + Font Software, including without limitation the rights to use, copy, merge, + publish, distribute, and/or sell copies of the Font Software, and to permit + persons to whom the Font Software is furnished to do so, subject to the + following conditions: + . + The above copyright and trademark notices and this permission notice shall + be included in all copies of one or more of the Font Software typefaces. + . + The Font Software may be modified, altered, or added to, and in particular + the designs of glyphs or characters in the Fonts may be modified and + additional glyphs or characters may be added to the Fonts, only if the fonts + are renamed to names not containing either the words "Bitstream" or the word + "Vera". + . + This License becomes null and void to the extent applicable to Fonts or Font + Software that has been modified and is distributed under the "Bitstream + Vera" names. + . + The Font Software may be sold as part of a larger software package but no + copy of one or more of the Font Software typefaces may be sold by itself. + . + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, + TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME + FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING + ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF + THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE + FONT SOFTWARE. + . + Except as contained in this notice, the names of Gnome, the Gnome + Foundation, and Bitstream Inc., shall not be used in advertising or + otherwise to promote the sale, use or other dealings in this Font Software + without prior written authorization from the Gnome Foundation or Bitstream + Inc., respectively. For further information, contact: fonts at gnome dot + org. + +Files: debian/* +Copyright: (C) 2005-2006 Peter Cernak + (C) 2006-2011 Davide Viti + (C) 2011-2013 Christian Perrier + (C) 2013 Fabian Greffrath +License: GPL-2+ + This program is free software; you can redistribute it + and/or modify it under the terms of the GNU General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later + version. + . + This program is distributed in the hope that it will be + useful, but WITHOUT ANY WARRANTY; without even the implied + warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + PURPOSE. See the GNU General Public License for more + details. + . + You should have received a copy of the GNU General Public + License along with this package; if not, write to the Free + Software Foundation, Inc., 51 Franklin St, Fifth Floor, + Boston, MA 02110-1301 USA + . + On Debian systems, the full text of the GNU General Public + License version 2 can be found in the file + /usr/share/common-licenses/GPL-2'. diff --git a/graph-ui/public/fonts/DejaVuSans.ttf b/graph-ui/public/fonts/DejaVuSans.ttf new file mode 100644 index 000000000..725d11748 Binary files /dev/null and b/graph-ui/public/fonts/DejaVuSans.ttf differ diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index ea9b7bb5b..0f3ff7d0e 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -6,7 +6,7 @@ import { computeCameraTarget, type CameraTarget, } from "./GraphScene"; -import { Sidebar } from "./Sidebar"; +import { LevelList } from "./LevelList"; import { FilterPanel } from "./FilterPanel"; import { NodeDetailPanel } from "./NodeDetailPanel"; import { ResizeHandle } from "./ResizeHandle"; @@ -29,10 +29,20 @@ interface GraphTabProps { project: string | null; } +/* A breadcrumb in the drill-down path. qn is the container's qualified_name + * (passed to /api/graph as `parent`); name is the segment shown to the user. */ +interface Crumb { + qn: string; + name: string; +} + export function GraphTab({ project }: GraphTabProps) { - const { data, loading, error, fetchOverview } = useGraphData(); + const { data, loading, error, fetchGraph } = useGraphData(); + /* Drill-down path. crumbs[0] is the repo root; the last crumb is the + * currently displayed container. */ + const [crumbs, setCrumbs] = useState([]); const [highlightedIds, setHighlightedIds] = useState | null>(null); - const [selectedPath, setSelectedPath] = useState(null); + const [, setSelectedPath] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [cameraTarget, setCameraTarget] = useState(null); const [showLabels, setShowLabels] = useState(true); @@ -87,35 +97,36 @@ export function GraphTab({ project }: GraphTabProps) { return { nodes, edges, total_nodes: data.total_nodes, linked_projects }; }, [data, enabledLabels, enabledEdgeTypes]); + /* On project change, reset to the repo root (parent = project name). */ useEffect(() => { if (project) { - fetchOverview(project); + const base = project.split(/[./]/).pop() || project; + setCrumbs([{ qn: project, name: base }]); + fetchGraph(project, project); setHighlightedIds(null); setSelectedPath(null); + setSelectedNode(null); } - }, [project, fetchOverview]); + }, [project, fetchGraph]); + + const handleNodeClick = useCallback( + (node: GraphNode) => { + if (!filteredData || !project) return; - const handleSelectPath = useCallback( - (path: string, nodeIds: Set) => { - if (!filteredData || !path || nodeIds.size === 0) { + /* Expandable container -> drill one level deeper. */ + if (node.expandable && node.qn) { + const qn = node.qn; + setCrumbs((prev) => [...prev, { qn, name: node.name }]); + fetchGraph(project, qn); setHighlightedIds(null); setSelectedPath(null); + setSelectedNode(null); setCameraTarget(null); return; } - setSelectedPath(path); - setHighlightedIds(nodeIds); - setCameraTarget(computeCameraTarget(filteredData.nodes, nodeIds)); - }, - [filteredData], - ); - const handleNodeClick = useCallback( - (node: GraphNode) => { - if (!filteredData) return; + /* Leaf -> select + highlight its direct connections. */ setSelectedNode(node); - - /* Highlight the node and its direct connections */ const connectedIds = new Set([node.id]); for (const edge of filteredData.edges) { if (edge.source === node.id) connectedIds.add(edge.target); @@ -125,7 +136,23 @@ export function GraphTab({ project }: GraphTabProps) { setSelectedPath(node.file_path ?? null); setCameraTarget(computeCameraTarget(filteredData.nodes, connectedIds)); }, - [filteredData], + [filteredData, project, fetchGraph], + ); + + /* Jump to an ancestor in the breadcrumb path. */ + const navigateToCrumb = useCallback( + (index: number) => { + if (!project) return; + const next = crumbs.slice(0, index + 1); + const target = next[next.length - 1]; + setCrumbs(next); + fetchGraph(project, target.qn); + setHighlightedIds(null); + setSelectedPath(null); + setSelectedNode(null); + setCameraTarget(null); + }, + [project, crumbs, fetchGraph], ); const handleNavigateToNode = useCallback( @@ -197,7 +224,11 @@ export function GraphTab({ project }: GraphTabProps) {

{error}

-
@@ -242,10 +273,10 @@ export function GraphTab({ project }: GraphTabProps) { onEnableAll={enableAll} onDisableAll={disableAll} /> -
+ {/* Breadcrumb drill path */} +
+ {crumbs.map((c, i) => ( + + {i > 0 && /} + + + ))} +
+ {/* HUD */} -
+

- {filteredData.nodes.length.toLocaleString()} nodes /{" "} - {filteredData.edges.length.toLocaleString()} edges + {filteredData.nodes.length.toLocaleString()} groups /{" "} + {filteredData.edges.length.toLocaleString()} links +

+

+ click a node to drill in - size = code volume

- {data.nodes.length > filteredData.nodes.length && ( -

- filtered from {data.nodes.length.toLocaleString()} -

- )} {highlightedIds && highlightedIds.size > 0 && ( -

- {highlightedIds.size} selected -

+

{highlightedIds.size} selected

)}
@@ -311,7 +359,7 @@ export function GraphTab({ project }: GraphTabProps) { setSelectedPath(null); setSelectedNode(null); setCameraTarget(null); - fetchOverview(project); + fetchGraph(project, crumbs[crumbs.length - 1]?.qn ?? project); }} > Refresh diff --git a/graph-ui/src/components/LevelList.tsx b/graph-ui/src/components/LevelList.tsx new file mode 100644 index 000000000..f8f2ba055 --- /dev/null +++ b/graph-ui/src/components/LevelList.tsx @@ -0,0 +1,69 @@ +import { useMemo, useState } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import type { GraphNode } from "../lib/types"; + +interface LevelListProps { + nodes: GraphNode[]; + onPick: (node: GraphNode) => void; + selectedId: number | null; +} + +/* Aim-free navigation for the drill-down explorer: a plain clickable list of the + * current level's container nodes. Clicking a row drills in (expandable) or + * selects (leaf) - identical to clicking the 3D node, but without having to aim + * at a small sphere. Sorted by code volume (descending). */ +export function LevelList({ nodes, onPick, selectedId }: LevelListProps) { + const [search, setSearch] = useState(""); + const rows = useMemo(() => { + const q = search.trim().toLowerCase(); + return [...nodes] + .filter((n) => !q || n.name.toLowerCase().includes(q)) + .sort((a, b) => (b.count ?? 0) - (a.count ?? 0)); + }, [nodes, search]); + + return ( +
+
+ setSearch(e.target.value)} + className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-1.5 text-[12px] text-foreground placeholder-foreground/25 outline-none focus:border-primary/40 focus:bg-white/[0.06] transition-all" + /> +
+ +
+ {rows.length === 0 ? ( +

No matches

+ ) : ( + rows.map((n) => ( + + )) + )} +
+
+
+ ); +} diff --git a/graph-ui/src/components/NodeCloud.tsx b/graph-ui/src/components/NodeCloud.tsx index e5465b8f2..bb428344c 100644 --- a/graph-ui/src/components/NodeCloud.tsx +++ b/graph-ui/src/components/NodeCloud.tsx @@ -55,7 +55,9 @@ export function NodeCloud({ const n = nodes[i]; tempObj.position.set(n.x, n.y, n.z); const isHighlighted = !hasHighlight || highlightedIds.has(n.id); - const s = n.size * (isHighlighted ? 0.5 : 0.2); + /* Larger solid spheres so the clickable geometry roughly matches the + * bloom glow - clicking the visible halo previously missed the tiny core. */ + const s = n.size * (isHighlighted ? 0.9 : 0.35); tempObj.scale.set(s, s, s); tempObj.updateMatrix(); mesh.setMatrixAt(i, tempObj.matrix); diff --git a/graph-ui/src/hooks/useGraphData.ts b/graph-ui/src/hooks/useGraphData.ts index 36048bcac..96a8890f7 100644 --- a/graph-ui/src/hooks/useGraphData.ts +++ b/graph-ui/src/hooks/useGraphData.ts @@ -5,16 +5,19 @@ interface UseGraphDataResult { data: GraphData | null; loading: boolean; error: string | null; - fetchOverview: (project: string) => void; - fetchDetail: (project: string, centerNode: string) => void; + /* Load one hierarchy level of the drill-down explorer. `parent` is a container + * qualified_name; omit (or pass the project) for the repo root. */ + fetchGraph: (project: string, parent?: string) => void; } -async function fetchLayout( - project: string, - maxNodes = 50000, -): Promise { - const params = new URLSearchParams({ project, max_nodes: String(maxNodes) }); - const res = await fetch(`/api/layout?${params}`); +/* The drill-down explorer fetches aggregated container nodes + weighted + * super-edges from /api/graph. The backend only ever materializes the current + * level (a few hundred nodes), so this is memory-safe for any repo size - + * unlike /api/layout, which tried to lay out the whole graph and OOM'd. */ +async function fetchLevel(project: string, parent?: string): Promise { + const params = new URLSearchParams({ project }); + if (parent) params.set("parent", parent); + const res = await fetch(`/api/graph?${params}`); if (!res.ok) { const body = await res.json().catch(() => ({ error: res.statusText })); @@ -29,35 +32,18 @@ export function useGraphData(): UseGraphDataResult { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const fetchOverview = useCallback(async (project: string) => { + const fetchGraph = useCallback(async (project: string, parent?: string) => { setLoading(true); setError(null); try { - const result = await fetchLayout(project, 50000); + const result = await fetchLevel(project, parent); setData(result); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to fetch layout"); + setError(e instanceof Error ? e.message : "Failed to load graph"); } finally { setLoading(false); } }, []); - const fetchDetail = useCallback( - async (project: string, _centerNode: string) => { - setLoading(true); - setError(null); - try { - /* TODO: detail level with center_node filtering */ - const result = await fetchLayout(project, 50000); - setData(result); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to fetch layout"); - } finally { - setLoading(false); - } - }, - [], - ); - - return { data, loading, error, fetchOverview, fetchDetail }; + return { data, loading, error, fetchGraph }; } diff --git a/graph-ui/src/lib/types.ts b/graph-ui/src/lib/types.ts index 0286a35b5..39a37b275 100644 --- a/graph-ui/src/lib/types.ts +++ b/graph-ui/src/lib/types.ts @@ -10,12 +10,17 @@ export interface GraphNode { file_path?: string; size: number; color: string; + /* Drill-down explorer (aggregated container nodes from /api/graph): */ + count?: number; /* number of code symbols in this subtree */ + expandable?: boolean; /* can be drilled into */ + qn?: string; /* full qualified_name; pass as the next `parent` to drill in */ } export interface GraphEdge { source: number; target: number; type: string; + weight?: number; /* aggregated super-edge weight (drill-down explorer) */ } export interface LinkedProject { @@ -31,6 +36,7 @@ export interface GraphData { edges: GraphEdge[]; total_nodes: number; linked_projects?: LinkedProject[]; + prefix?: string; /* current container QN (drill-down explorer) */ } export interface Project { diff --git a/graph-ui/src/main.tsx b/graph-ui/src/main.tsx index e95ed1bd2..20a12e310 100644 --- a/graph-ui/src/main.tsx +++ b/graph-ui/src/main.tsx @@ -1,6 +1,13 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +/* Self-hosted fonts (bundled by vite) - replaces the Google Fonts CDN link so + * the UI makes no external requests when viewing a proprietary codebase. */ +import "@fontsource/inter/400.css"; +import "@fontsource/inter/500.css"; +import "@fontsource/inter/600.css"; +import "@fontsource/jetbrains-mono/400.css"; +import "@fontsource/jetbrains-mono/500.css"; import "./styles/globals.css"; createRoot(document.getElementById("root")!).render( diff --git a/graph-ui/tsconfig.tsbuildinfo b/graph-ui/tsconfig.tsbuildinfo index e1401cf36..618452f0c 100644 --- a/graph-ui/tsconfig.tsbuildinfo +++ b/graph-ui/tsconfig.tsbuildinfo @@ -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.tsx","./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.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.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"} \ No newline at end of file +{"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.tsx","./src/components/GraphTab.tsx","./src/components/LevelList.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.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.ts","./src/hooks/useProjects.ts","./src/lib/colors.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/scripts/embed-frontend.sh b/scripts/embed-frontend.sh index 908c39790..edef97013 100755 --- a/scripts/embed-frontend.sh +++ b/scripts/embed-frontend.sh @@ -38,6 +38,8 @@ content_type_for() { *.ico) echo "image/x-icon" ;; *.woff2) echo "font/woff2" ;; *.woff) echo "font/woff" ;; + *.ttf) echo "font/ttf" ;; + *.otf) echo "font/otf" ;; *.map) echo "application/json" ;; *) echo "application/octet-stream" ;; esac diff --git a/src/cli/cli.c b/src/cli/cli.c index f159f5914..8fddcc18b 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -3898,9 +3898,28 @@ static int download_verify_install(const char *url, const char *ext, const char want_ui ? "ui-" : "", os, arch, portable, ext); int crc = verify_download_checksum(tmp_archive, archive_name); if (crc == CLI_TRUE) { + /* Explicit checksum mismatch: the binary is tampered or corrupt. Never install. */ cbm_unlink(tmp_archive); return CLI_TRUE; } + if (crc != 0) { + /* "Could not verify" (missing checksums.txt, name absent, or no sha256 tool). + * Fail closed: do not install an unverified binary. An operator on an + * isolated/offline host may opt in explicitly via CBM_ALLOW_UNVERIFIED_UPDATE=1. */ + char allow_buf[CLI_BUF_16]; + const char *allow = + cbm_safe_getenv("CBM_ALLOW_UNVERIFIED_UPDATE", allow_buf, sizeof(allow_buf), NULL); + if (!allow || strcmp(allow, "1") != 0) { + (void)fprintf(stderr, + "error: could not verify the downloaded binary - aborting.\n" + " Verification is required by default. To override on an offline host, " + "set CBM_ALLOW_UNVERIFIED_UPDATE=1.\n"); + cbm_unlink(tmp_archive); + return CLI_TRUE; + } + (void)fprintf(stderr, "warning: installing UNVERIFIED binary " + "(CBM_ALLOW_UNVERIFIED_UPDATE=1).\n"); + } int killed = cbm_kill_other_instances(); if (killed > 0) { diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 7016a0d21..f13fa3544 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -4737,6 +4737,15 @@ static void start_update_check(cbm_mcp_server_t *srv) { if (srv->update_checked) { return; } + /* Opt-out: skip the GitHub version check entirely for restricted-egress or + * air-gapped environments. Set CBM_NO_UPDATE_CHECK=1 to disable. */ + char optout_buf[8]; + const char *optout = + cbm_safe_getenv("CBM_NO_UPDATE_CHECK", optout_buf, sizeof(optout_buf), NULL); + if (optout && strcmp(optout, "1") == 0) { + srv->update_checked = true; /* mark done so we never launch the thread */ + return; + } srv->update_checked = true; /* prevent double-launch */ if (cbm_thread_create(&srv->update_tid, 0, update_check_thread, srv) == 0) { srv->update_thread_active = true; diff --git a/src/store/store.c b/src/store/store.c index 995f6e85c..0ad9db1db 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -2348,12 +2348,36 @@ static int search_where_basic(const cbm_search_params_t *params, char *where, in *wlen = where_append(where, where_sz, *wlen, nparams, bind_buf); where_bind_text(binds, bind_idx, params->project); } - /* Ignore an empty-string label: it is non-NULL but should behave like an - * omitted label (no filter), matching the BM25 query path. Without the - * params->label[0] guard, name_pattern/qn_pattern searches that pass - * label="" append `n.label = ''`, which matches no node and silently - * returns zero results (issue #481). */ - if (params->label && params->label[0]) { + if (params->include_labels && params->include_labels[0]) { + /* n.label IN (?,?,...) - one bind per whitelisted label. Track the write + * offset as a clamped size_t so the remaining-size passed to snprintf can + * never underflow, even on truncation (snprintf returns the would-be + * length, which may exceed the buffer). */ + char in_buf[CBM_SZ_256]; + const size_t cap = sizeof(in_buf); + int head = snprintf(in_buf, cap, "n.label IN ("); + size_t off = (head > 0) ? (size_t)head : 0; + if (off >= cap) + off = cap - 1; + int base = *bind_idx; + int k = 0; + for (; k < ST_SEARCH_MAX_BINDS && params->include_labels[k]; k++) { + int w = snprintf(in_buf + off, cap - off, "%s?%d", k ? "," : "", base + SKIP_ONE + k); + if (w < 0) + break; + off += (size_t)w; + if (off >= cap) { /* truncated: stop before the size could underflow */ + off = cap - 1; + break; + } + } + snprintf(in_buf + off, cap - off, ")"); + *wlen = where_append(where, where_sz, *wlen, nparams, in_buf); + for (int i = 0; i < k; i++) + where_bind_text(binds, bind_idx, params->include_labels[i]); + } else if (params->label && params->label[0]) { + /* Ignore an empty-string label (issue #481): non-NULL but behaves like an + * omitted label, matching the BM25 query path. */ snprintf(bind_buf, sizeof(bind_buf), "n.label = ?%d", *bind_idx + SKIP_ONE); *wlen = where_append(where, where_sz, *wlen, nparams, bind_buf); where_bind_text(binds, bind_idx, params->label); @@ -2565,6 +2589,201 @@ void cbm_store_search_free(cbm_search_output_t *out) { memset(out, 0, sizeof(*out)); } +/* ── Hierarchy expansion (semantic-zoom drill-down) ─────────────────── */ + +void cbm_tree_view_free(cbm_tree_view_t *v) { + if (!v) { + return; + } + for (int i = 0; i < v->child_count; i++) { + free(v->children[i].name); + free(v->children[i].full_qn); + free(v->children[i].kind); + } + free(v->children); + free(v->edges); + memset(v, 0, sizeof(*v)); +} + +/* SQL fragment: the QN segment of column `col` immediately after the bound + * prefix (param ?2 = length of "prefix."). Reused in the child-grouping and + * edge-aggregation queries. SUBSTR(col, ?2 + 1) is the remainder after the + * prefix; the first dotted token of that remainder is the child segment. */ +#define CBM_TREE_SEG(col) \ + "CASE WHEN INSTR(SUBSTR(" col ", ?2 + 1), '.') > 0 " \ + " THEN SUBSTR(SUBSTR(" col ", ?2 + 1), 1, INSTR(SUBSTR(" col ", ?2 + 1), '.') - 1) " \ + " ELSE SUBSTR(" col ", ?2 + 1) END" + +static int tree_child_index(const cbm_tree_view_t *v, const char *name) { + for (int i = 0; i < v->child_count; i++) { + if (strcmp(v->children[i].name, name) == 0) { + return i; + } + } + return CBM_NOT_FOUND; +} + +int cbm_store_expand_tree(cbm_store_t *s, const char *project, const char *prefix, int child_limit, + int edge_limit, cbm_tree_view_t *out) { + if (!s || !s->db || !project || !prefix || !out) { + return CBM_STORE_ERR; + } + memset(out, 0, sizeof(*out)); + if (child_limit <= 0) { + child_limit = 500; + } + if (edge_limit <= 0) { + edge_limit = 2000; + } + + size_t plen = strlen(prefix); + /* Range bounds so the (project, qualified_name) index can prefix-scan: + * every child QN is "prefix" + "." + ..., and '.' (0x2E) < '/' (0x2F), so + * [prefix+".", prefix+"/") is exactly the set of descendants. This replaces a + * non-sargable SUBSTR(...) = ? predicate that forced a full table scan. */ + char *lo = malloc(plen + 2); + char *hi = malloc(plen + 2); + if (!lo || !hi) { + free(lo); + free(hi); + return CBM_STORE_ERR; + } + memcpy(lo, prefix, plen); + lo[plen] = '.'; + lo[plen + 1] = '\0'; + memcpy(hi, prefix, plen); + hi[plen] = '/'; + hi[plen + 1] = '\0'; + int substr_len = (int)plen + 1; /* offset of the segment after "prefix." */ + + /* 1. Children: group nodes one level below `prefix` by their next QN segment. */ + const char *child_sql = "SELECT " CBM_TREE_SEG( + "qualified_name") " AS child, COUNT(*) AS cnt, " + "MAX(CASE WHEN INSTR(SUBSTR(qualified_name, ?2 + 1), '.') > 0 " + " THEN 1 ELSE 0 END) AS expandable, " + "MAX(CASE WHEN INSTR(SUBSTR(qualified_name, ?2 + 1), '.') = 0 " + " THEN label END) AS kind " + "FROM nodes WHERE project = ?1 " + "AND qualified_name >= ?3 AND qualified_name < ?4 " + "GROUP BY child ORDER BY cnt DESC LIMIT ?5"; + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(s->db, child_sql, CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) { + free(lo); + free(hi); + return CBM_STORE_ERR; + } + sqlite3_bind_text(st, 1, project, CBM_NOT_FOUND, SQLITE_STATIC); + sqlite3_bind_int(st, 2, substr_len); + sqlite3_bind_text(st, 3, lo, CBM_NOT_FOUND, SQLITE_STATIC); + sqlite3_bind_text(st, 4, hi, CBM_NOT_FOUND, SQLITE_STATIC); + sqlite3_bind_int(st, 5, child_limit); + + int cap = 16, n = 0; + cbm_tree_child_t *children = malloc((size_t)cap * sizeof(*children)); + if (!children) { + sqlite3_finalize(st); + free(lo); + free(hi); + return CBM_STORE_ERR; + } + while (sqlite3_step(st) == SQLITE_ROW) { + const char *child = (const char *)sqlite3_column_text(st, 0); + if (!child || child[0] == '\0') { + continue; + } + if (n >= cap) { + cap *= 2; + cbm_tree_child_t *tmp = realloc(children, (size_t)cap * sizeof(*children)); + if (!tmp) { + break; + } + children = tmp; + } + const char *kind = (const char *)sqlite3_column_text(st, 3); + size_t clen = strlen(child); + char *fq = malloc(plen + 1 + clen + 1); + if (fq) { + memcpy(fq, prefix, plen); + fq[plen] = '.'; + memcpy(fq + plen + 1, child, clen + 1); + } + children[n].name = heap_strdup(child); + children[n].full_qn = fq; + children[n].kind = heap_strdup(kind ? kind : "Group"); + children[n].count = sqlite3_column_int(st, 1); + children[n].expandable = sqlite3_column_int(st, 2); + n++; + } + sqlite3_finalize(st); + out->children = children; + out->child_count = n; + if (n == 0) { + free(lo); + free(hi); + return CBM_STORE_OK; + } + + /* 2. Edges: aggregate cross-child relationship edges into weighted super-edges. + * Drive from the (project, qualified_name) index range on each endpoint so the + * planner restricts to the current subtree before the COUNT/GROUP, instead of + * scanning every typed edge in the project. */ + const char *edge_sql = "SELECT " CBM_TREE_SEG("ns.qualified_name") " AS src, " CBM_TREE_SEG( + "nt.qualified_name") " AS tgt, COUNT(*) AS w " + "FROM edges e JOIN nodes ns ON ns.id = e.source_id JOIN nodes nt ON " + "nt.id = e.target_id " + "WHERE e.project = ?1 AND e.type IN " + "('CALLS','IMPORTS','INHERITS','USAGE','WRITES','CONFIGURES','HTTP_" + "CALLS','THROWS') " + "AND ns.qualified_name >= ?3 AND ns.qualified_name < ?4 " + "AND nt.qualified_name >= ?3 AND nt.qualified_name < ?4 " + "AND " CBM_TREE_SEG("ns.qualified_name") " <> " CBM_TREE_SEG( + "nt.qualified_name") " " + "GROUP BY src, tgt ORDER BY w DESC LIMIT ?5"; + + if (sqlite3_prepare_v2(s->db, edge_sql, CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) { + free(lo); + free(hi); + return CBM_STORE_OK; /* children are still valid */ + } + sqlite3_bind_text(st, 1, project, CBM_NOT_FOUND, SQLITE_STATIC); + sqlite3_bind_int(st, 2, substr_len); + sqlite3_bind_text(st, 3, lo, CBM_NOT_FOUND, SQLITE_STATIC); + sqlite3_bind_text(st, 4, hi, CBM_NOT_FOUND, SQLITE_STATIC); + sqlite3_bind_int(st, 5, edge_limit); + + int ecap = 32, en = 0; + cbm_tree_edge_t *edges = malloc((size_t)ecap * sizeof(*edges)); + while (edges && sqlite3_step(st) == SQLITE_ROW) { + const char *sc = (const char *)sqlite3_column_text(st, 0); + const char *tc = (const char *)sqlite3_column_text(st, 1); + int si = sc ? tree_child_index(out, sc) : CBM_NOT_FOUND; + int di = tc ? tree_child_index(out, tc) : CBM_NOT_FOUND; + if (si < 0 || di < 0) { + continue; /* endpoint truncated by child_limit */ + } + if (en >= ecap) { + ecap *= 2; + cbm_tree_edge_t *tmp = realloc(edges, (size_t)ecap * sizeof(*edges)); + if (!tmp) { + break; + } + edges = tmp; + } + edges[en].src = si; + edges[en].dst = di; + edges[en].weight = sqlite3_column_int(st, 2); + en++; + } + sqlite3_finalize(st); + out->edges = edges; + out->edge_count = en; + + free(lo); + free(hi); + return CBM_STORE_OK; +} + /* ── BFS Traversal ──────────────────────────────────────────────── */ static int bfs_collect_edges(cbm_store_t *s, int64_t start_id, const cbm_node_hop_t *visited, diff --git a/src/store/store.h b/src/store/store.h index 26b09a5c2..c54376ad0 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -104,15 +104,16 @@ int cbm_store_restore_from(cbm_store_t *dst, cbm_store_t *src); typedef struct { const char *project; - const char *label; /* NULL = any label */ - const char *name_pattern; /* regex on name, NULL = any */ - const char *qn_pattern; /* regex on qualified_name, NULL = any */ - const char *file_pattern; /* glob on file_path, NULL = any */ - const char *relationship; /* edge type filter, NULL = any */ - const char *direction; /* "inbound" / "outbound" / "any", NULL = any */ - int min_degree; /* -1 = no filter (default), 0+ = minimum */ - int max_degree; /* -1 = no filter (default), 0+ = maximum */ - int limit; /* 0 = default (10) */ + const char *label; /* NULL = any label */ + const char **include_labels; /* NULL-terminated whitelist (label IN (...)); overrides `label` */ + const char *name_pattern; /* regex on name, NULL = any */ + const char *qn_pattern; /* regex on qualified_name, NULL = any */ + const char *file_pattern; /* glob on file_path, NULL = any */ + const char *relationship; /* edge type filter, NULL = any */ + const char *direction; /* "inbound" / "outbound" / "any", NULL = any */ + int min_degree; /* -1 = no filter (default), 0+ = minimum */ + int max_degree; /* -1 = no filter (default), 0+ = maximum */ + int limit; /* 0 = default (10) */ int offset; bool exclude_entry_points; bool include_connected; @@ -380,6 +381,41 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear /* Free a search output's allocated memory. */ void cbm_store_search_free(cbm_search_output_t *out); +/* ── Hierarchy expansion (semantic-zoom drill-down) ───────────────── + * Groups nodes one level below a qualified_name prefix by their next QN + * segment, and aggregates cross-group relationship edges into weighted + * super-edges. Lets the UI represent a 300k-node graph as a navigable tree + * of container super-nodes without ever materializing the whole graph. */ +typedef struct { + char *name; /* child segment (the next QN token under the prefix) */ + char *full_qn; /* prefix + "." + name (pass back as the next prefix to drill in) */ + char *kind; /* label of the container/leaf node for this segment, or "Group" */ + int count; /* number of nodes in this subtree */ + int expandable; /* 1 if the subtree has deeper nodes (can drill in) */ +} cbm_tree_child_t; + +typedef struct { + int src; /* index into children[] */ + int dst; /* index into children[] */ + int weight; /* number of underlying relationship edges crossing the boundary */ +} cbm_tree_edge_t; + +typedef struct { + cbm_tree_child_t *children; + int child_count; + cbm_tree_edge_t *edges; + int edge_count; +} cbm_tree_view_t; + +/* Expand one hierarchy level under `prefix` (a node qualified_name, e.g. the + * project name for the root). Caps children at child_limit and edges at + * edge_limit. Returns CBM_STORE_OK and fills `out` (free with cbm_tree_view_free). */ +int cbm_store_expand_tree(cbm_store_t *s, const char *project, const char *prefix, int child_limit, + int edge_limit, cbm_tree_view_t *out); + +/* Free a tree view's allocated memory. */ +void cbm_tree_view_free(cbm_tree_view_t *v); + /* ── Traversal ──────────────────────────────────────────────────── */ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char **edge_types, diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 568b47cc0..b75b04dcb 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -105,6 +105,25 @@ typedef struct { static index_job_t g_index_jobs[MAX_INDEX_JOBS]; +/* Content-Security-Policy for the UI document. The loopback server and its + * embedded assets are entirely self-contained, so every fetch directive is + * pinned to 'self'. This makes the browser refuse any external request - + * fonts, scripts, XHR/fetch/WebSocket, images - so viewing a proprietary + * codebase in the graph UI cannot leak data to any third-party host. + * script/style allow 'unsafe-inline'/'unsafe-eval' and worker allows blob: + * because the bundled React + three.js/troika stack needs them; none of those + * widen network egress, which is governed by connect-src/font-src/img-src. */ +/* Note on blob: in script-src - three.js/troika build Web Workers from blob: + * URLs and those workers call importScripts(blob:...), which is governed by + * script-src (script-src-elem falls back to it), not worker-src. blob: is a + * local, same-origin scheme and does NOT permit any network egress; egress is + * still fully pinned by connect-src/font-src/img-src 'self'. */ +#define CBM_UI_CSP \ + "Content-Security-Policy: default-src 'self'; connect-src 'self'; font-src 'self'; " \ + "img-src 'self' data: blob:; media-src 'self' data: blob:; " \ + "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; " \ + "worker-src 'self' blob:; object-src 'none'; base-uri 'self'\r\n" + /* ── Serve embedded asset ─────────────────────────────────────── */ static bool serve_embedded(cbm_http_conn_t *c, const char *path) { @@ -976,6 +995,14 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { max_nodes = v; } + /* Optional label filter. Empty string ("label=") explicitly means "all labels"; + * an absent param keeps the default. */ + char label[64] = {0}; + const char *label_filter = NULL; + if (cbm_http_query_param(req->query, "label", label, (int)sizeof(label))) { + label_filter = label[0] != '\0' ? label : NULL; + } + char db_path[1024]; db_path_for_project(project, db_path, sizeof(db_path)); @@ -991,7 +1018,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { } cbm_layout_result_t *layout = - cbm_layout_compute(store, project, CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes); + cbm_layout_compute(store, project, CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes, label_filter); /* Find linked projects from CROSS_* edges. Keep `store` open through the * linked-projects loop below so we can resolve target Route QNs against @@ -1054,8 +1081,8 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { } /* Keep lp_store open through cross_edges resolution below. */ - cbm_layout_result_t *lp_layout = - cbm_layout_compute(lp_store, linked[li], CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes); + cbm_layout_result_t *lp_layout = cbm_layout_compute( + lp_store, linked[li], CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes, label_filter); if (!lp_layout) { cbm_store_close(lp_store); @@ -1190,6 +1217,186 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { } } +/* Color for an aggregated container super-node, keyed by its dominant node label. */ +static uint32_t tree_kind_color(const char *kind) { + if (!kind) + return 0x8888aa; + if (strcmp(kind, "Folder") == 0) + return 0xf2c14e; + if (strcmp(kind, "File") == 0) + return 0x4ea8de; + if (strcmp(kind, "Class") == 0) + return 0x9b5de5; + if (strcmp(kind, "Interface") == 0) + return 0xc77dff; + if (strcmp(kind, "Method") == 0) + return 0x00bbf9; + if (strcmp(kind, "Function") == 0) + return 0x00f5d4; + if (strcmp(kind, "Module") == 0) + return 0xff8fab; + if (strcmp(kind, "Variable") == 0) + return 0xfee440; + if (strcmp(kind, "Route") == 0) + return 0xff5d8f; + if (strcmp(kind, "Enum") == 0 || strcmp(kind, "Type") == 0) + return 0x90be6d; + return 0x8888aa; /* Group / unknown */ +} + +/* Memo cache for /api/graph. The aggregation over a large subtree scans many + * edges (~seconds at the root), but the index is static while the server runs, + * so each (project,parent) view is computed once and reused. The HTTP server is + * single-threaded per request (see httpd.c), so no locking is needed. */ +#define GRAPH_CACHE_MAX 64 +static struct { + char *key; + char *json; +} g_graph_cache[GRAPH_CACHE_MAX]; +static int g_graph_cache_n = 0; +static int g_graph_cache_next = 0; /* FIFO eviction cursor when full */ + +static const char *graph_cache_get(const char *key) { + for (int i = 0; i < g_graph_cache_n; i++) { + if (g_graph_cache[i].key && strcmp(g_graph_cache[i].key, key) == 0) { + return g_graph_cache[i].json; + } + } + return NULL; +} + +static void graph_cache_put(const char *key, const char *json) { + char *kd = strdup(key); + char *jd = strdup(json); + if (!kd || !jd) { + free(kd); + free(jd); + return; + } + int slot; + if (g_graph_cache_n < GRAPH_CACHE_MAX) { + slot = g_graph_cache_n++; + } else { + slot = g_graph_cache_next; + g_graph_cache_next = (g_graph_cache_next + 1) % GRAPH_CACHE_MAX; + free(g_graph_cache[slot].key); + free(g_graph_cache[slot].json); + } + g_graph_cache[slot].key = kd; + g_graph_cache[slot].json = jd; +} + +/* GET /api/graph?project=X&parent= — one hierarchy level for the drill-down + * explorer: aggregated container children + weighted cross-container super-edges. + * `parent` defaults to the project root. Memory-safe for any repo size: only the + * (capped) children + super-edges are materialized, never the full graph. */ +static void handle_graph(cbm_http_conn_t *c, const cbm_http_req_t *req) { + char project[256] = {0}; + char parent[1024] = {0}; + if (!cbm_http_query_param(req->query, "project", project, (int)sizeof(project)) || + project[0] == '\0') { + cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing project parameter\"}"); + return; + } + /* Root = the project node, whose qualified_name equals the project name. */ + if (!cbm_http_query_param(req->query, "parent", parent, (int)sizeof(parent)) || + parent[0] == '\0') { + snprintf(parent, sizeof(parent), "%s", project); + } + + /* Cache key = project + '\n' + parent. */ + char cache_key[1300]; + snprintf(cache_key, sizeof(cache_key), "%s\n%s", project, parent); + const char *cached = graph_cache_get(cache_key); + if (cached) { + cbm_http_replyf(c, 200, g_cors_json, "%s", cached); + return; + } + + char db_path[1024]; + db_path_for_project(project, db_path, sizeof(db_path)); + if (!cbm_file_exists(db_path)) { + cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"project not found\"}"); + return; + } + cbm_store_t *store = cbm_store_open_path(db_path); + if (!store) { + cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}"); + return; + } + + cbm_tree_view_t view; + if (cbm_store_expand_tree(store, project, parent, 500, 2000, &view) != CBM_STORE_OK) { + cbm_store_close(store); + cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"expand failed\"}"); + return; + } + + int n = view.child_count; + /* Spread nodes wider so they overlap less and present larger click targets. */ + double radius = 90.0 + sqrt((double)(n > 0 ? n : 1)) * 26.0; + double golden = M_PI * (3.0 - sqrt(5.0)); + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + + yyjson_mut_val *na = yyjson_mut_arr(doc); + for (int i = 0; i < n; i++) { + cbm_tree_child_t *ch = &view.children[i]; + yyjson_mut_val *nd = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, nd, "id", i); + /* Fibonacci sphere placement so the overview reads as a ball of nodes. */ + double yy = n > 1 ? 1.0 - (i / (double)(n - 1)) * 2.0 : 0.0; + double rr = sqrt(1.0 - yy * yy); + double th = golden * i; + yyjson_mut_obj_add_real(doc, nd, "x", cos(th) * rr * radius); + yyjson_mut_obj_add_real(doc, nd, "y", yy * radius); + yyjson_mut_obj_add_real(doc, nd, "z", sin(th) * rr * radius); + yyjson_mut_obj_add_str(doc, nd, "label", ch->kind ? ch->kind : "Group"); + yyjson_mut_obj_add_str(doc, nd, "name", ch->name ? ch->name : ""); + double sz = 4.0 + log((double)(ch->count > 0 ? ch->count : 1) + 1.0) * 2.6; + yyjson_mut_obj_add_real(doc, nd, "size", sz); + char hex[16]; + snprintf(hex, sizeof(hex), "#%06x", tree_kind_color(ch->kind)); + yyjson_mut_obj_add_strcpy(doc, nd, "color", hex); + yyjson_mut_obj_add_int(doc, nd, "count", ch->count); + yyjson_mut_obj_add_bool(doc, nd, "expandable", ch->expandable != 0); + if (ch->full_qn) { + yyjson_mut_obj_add_strcpy(doc, nd, "qn", ch->full_qn); + } + yyjson_mut_arr_append(na, nd); + } + yyjson_mut_obj_add_val(doc, root, "nodes", na); + + yyjson_mut_val *ea = yyjson_mut_arr(doc); + for (int i = 0; i < view.edge_count; i++) { + yyjson_mut_val *ed = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, ed, "source", view.edges[i].src); + yyjson_mut_obj_add_int(doc, ed, "target", view.edges[i].dst); + yyjson_mut_obj_add_str(doc, ed, "type", "AGG"); + yyjson_mut_obj_add_int(doc, ed, "weight", view.edges[i].weight); + yyjson_mut_arr_append(ea, ed); + } + yyjson_mut_obj_add_val(doc, root, "edges", ea); + yyjson_mut_obj_add_int(doc, root, "total_nodes", n); + yyjson_mut_obj_add_strcpy(doc, root, "prefix", parent); + + size_t len = 0; + char *json = yyjson_mut_write_opts(doc, YYJSON_WRITE_ALLOW_INVALID_UNICODE, NULL, &len, NULL); + yyjson_mut_doc_free(doc); + cbm_tree_view_free(&view); + cbm_store_close(store); + + if (json) { + graph_cache_put(cache_key, json); + cbm_http_replyf(c, 200, g_cors_json, "%s", json); + free(json); + } else { + cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"JSON write failed\"}"); + } +} + /* ── Handle JSON-RPC request ──────────────────────────────────── */ static void handle_rpc(cbm_http_conn_t *c, const cbm_http_req_t *req, cbm_mcp_server_t *mcp) { @@ -1240,6 +1447,11 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, return; } + if (is_get && cbm_http_path_match(req->path, "/api/graph*")) { + handle_graph(c, req); + return; + } + /* POST /api/index → start background indexing */ if (is_post && cbm_http_path_match(req->path, "/api/index")) { handle_index_start(c, req); @@ -1304,9 +1516,9 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, if (cbm_http_path_match(req->path, "/")) { const cbm_embedded_file_t *f = cbm_embedded_lookup("/index.html"); if (f) { - char html_hdrs[512]; + char html_hdrs[1024]; snprintf(html_hdrs, sizeof(html_hdrs), - "%sContent-Type: text/html\r\nCache-Control: no-cache\r\n", g_cors); + "%sContent-Type: text/html\r\nCache-Control: no-cache\r\n" CBM_UI_CSP, g_cors); cbm_http_reply_buf(c, 200, html_hdrs, f->data, (size_t)f->size); return; } diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c index 5758a3334..e39f395a0 100644 --- a/src/ui/layout3d.c +++ b/src/ui/layout3d.c @@ -384,7 +384,7 @@ static int find_node_index(const node_id_entry_t *map, int count, int64_t id) { cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, cbm_layout_level_t level, const char *center_node, - int radius, int max_nodes) { + int radius, int max_nodes, const char *label) { if (!store || !project) return NULL; if (max_nodes <= 0) @@ -400,11 +400,75 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, params.limit = max_nodes; params.min_degree = -1; params.max_degree = -1; + /* Optional label filter. Accepts a comma-separated whitelist (e.g. + * "Class,Method,Variable"); NULL/empty means all labels. */ + char label_buf[CBM_SZ_256]; + const char *label_ptrs[16]; + int label_n = 0; + if (label && label[0] != '\0') { + snprintf(label_buf, sizeof(label_buf), "%s", label); + char *save = NULL; + for (char *tok = strtok_r(label_buf, ",", &save); + tok && label_n < (int)(sizeof(label_ptrs) / sizeof(label_ptrs[0])) - 1; + tok = strtok_r(NULL, ",", &save)) { + while (*tok == ' ') + tok++; + if (*tok != '\0') + label_ptrs[label_n++] = tok; + } + } + label_ptrs[label_n] = NULL; + params.include_labels = label_n > 0 ? label_ptrs : NULL; cbm_search_output_t search_out; memset(&search_out, 0, sizeof(search_out)); - if (cbm_store_search(store, ¶ms, &search_out) != CBM_STORE_OK) + if (label_n > 1) { + /* Multiple labels: give each its own quota so every requested type is + * represented. A flat "ORDER BY name LIMIT" lets one label (e.g. Variable) + * crowd out the rest. Run one search per label, then merge - transferring + * string ownership to the combined output (free only each sub's container + * array; cbm_store_search_free(&search_out) later frees the strings once). */ + int per = max_nodes / label_n; + if (per < 1) + per = 1; + cbm_search_output_t subs[16]; + int sub_count = 0; + int merged_cap = 0, merged_total = 0; + for (int li = 0; li < label_n; li++) { + const char *one[2] = {label_ptrs[li], NULL}; + cbm_search_params_t p = params; + p.include_labels = one; + p.limit = per; + memset(&subs[sub_count], 0, sizeof(subs[0])); + if (cbm_store_search(store, &p, &subs[sub_count]) == CBM_STORE_OK) { + merged_cap += subs[sub_count].count; + merged_total += subs[sub_count].total; + sub_count++; + } + } + int merged_ok = 0; + if (merged_cap > 0) { + search_out.results = malloc((size_t)merged_cap * sizeof(cbm_search_result_t)); + if (search_out.results) { + int idx = 0; + for (int s = 0; s < sub_count; s++) + for (int i = 0; i < subs[s].count; i++) + search_out.results[idx++] = subs[s].results[i]; + search_out.count = merged_cap; + search_out.total = merged_total; + for (int s = 0; s < sub_count; s++) + free(subs[s].results); /* container only; strings moved to search_out */ + merged_ok = 1; + } + } + if (!merged_ok) { + for (int s = 0; s < sub_count; s++) + cbm_store_search_free(&subs[s]); + return calloc(CBM_ALLOC_ONE, sizeof(cbm_layout_result_t)); + } + } else if (cbm_store_search(store, ¶ms, &search_out) != CBM_STORE_OK) { return calloc(CBM_ALLOC_ONE, sizeof(cbm_layout_result_t)); + } int n = search_out.count, total_count = search_out.total; if (n == 0) { diff --git a/src/ui/layout3d.h b/src/ui/layout3d.h index a939d6996..03fc95e5b 100644 --- a/src/ui/layout3d.h +++ b/src/ui/layout3d.h @@ -54,10 +54,11 @@ typedef enum { /* Compute layout for a project. * center_node: QN of center (for detail level), NULL for overview * radius: hop distance from center (for detail level) - * max_nodes: cap on returned nodes */ + * max_nodes: cap on returned nodes + * label: restrict to a single node label (e.g. "Class"), or NULL for all labels */ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, cbm_layout_level_t level, const char *center_node, - int radius, int max_nodes); + int radius, int max_nodes, const char *label); /* Free a layout result. */ void cbm_layout_free(cbm_layout_result_t *result); diff --git a/tests/test_ui.c b/tests/test_ui.c index 002e8d39a..daa85eea6 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -203,7 +203,7 @@ TEST(layout_empty_graph) { /* No nodes in store → empty result */ cbm_layout_result_t *r = - cbm_layout_compute(store, "test-project", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + cbm_layout_compute(store, "test-project", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NOT_NULL(r); ASSERT_EQ(r->node_count, 0); ASSERT_EQ(r->edge_count, 0); @@ -230,7 +230,8 @@ TEST(layout_single_node) { int64_t id = cbm_store_upsert_node(store, &node); ASSERT_GT(id, 0); - cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + cbm_layout_result_t *r = + cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NOT_NULL(r); ASSERT_EQ(r->node_count, 1); ASSERT_STR_EQ(r->nodes[0].name, "main"); @@ -267,7 +268,8 @@ TEST(layout_two_connected) { cbm_edge_t edge = {.project = "test", .source_id = id1, .target_id = id2, .type = "CALLS"}; cbm_store_insert_edge(store, &edge); - cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + cbm_layout_result_t *r = + cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NOT_NULL(r); ASSERT_EQ(r->node_count, 2); @@ -307,7 +309,8 @@ TEST(layout_respects_max_nodes) { } /* max_nodes=5 should return at most 5 */ - cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 5); + cbm_layout_result_t *r = + cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 5, NULL); ASSERT_NOT_NULL(r); ASSERT_LTE(r->node_count, 5); ASSERT_EQ(r->total_nodes, 20); @@ -341,8 +344,10 @@ TEST(layout_deterministic) { cbm_store_upsert_node(store, &n2); /* Run twice, check positions match */ - cbm_layout_result_t *r1 = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); - cbm_layout_result_t *r2 = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + cbm_layout_result_t *r1 = + cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); + cbm_layout_result_t *r2 = + cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NOT_NULL(r1); ASSERT_NOT_NULL(r2); ASSERT_EQ(r1->node_count, r2->node_count); @@ -374,7 +379,8 @@ TEST(layout_to_json) { .end_line = 5}; cbm_store_upsert_node(store, &n); - cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + cbm_layout_result_t *r = + cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NOT_NULL(r); char *json = cbm_layout_to_json(r); @@ -395,12 +401,13 @@ TEST(layout_to_json) { TEST(layout_null_inputs) { /* NULL store → NULL result */ - cbm_layout_result_t *r = cbm_layout_compute(NULL, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + cbm_layout_result_t *r = + cbm_layout_compute(NULL, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NULL(r); /* NULL project → NULL result */ cbm_store_t *store = cbm_store_open_memory(); - r = cbm_layout_compute(store, NULL, CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + r = cbm_layout_compute(store, NULL, CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL); ASSERT_NULL(r); /* cbm_layout_free(NULL) should not crash */ @@ -414,6 +421,72 @@ TEST(layout_null_inputs) { PASS(); } +/* ── Hierarchy expansion (drill-down explorer) ────────────────── */ + +TEST(expand_tree_groups_and_aggregates) { + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + cbm_store_upsert_project(store, "test", "/tmp/test"); + + /* Hierarchy under "test": two folders with children + a leaf module. + * QNs use '.' separators, matching the real indexer's scheme. */ + cbm_node_t nodes[] = { + {.project = "test", .label = "Folder", .name = "alpha", .qualified_name = "test.alpha"}, + {.project = "test", .label = "Method", .name = "x", .qualified_name = "test.alpha.x"}, + {.project = "test", .label = "Method", .name = "y", .qualified_name = "test.alpha.y"}, + {.project = "test", .label = "Folder", .name = "beta", .qualified_name = "test.beta"}, + {.project = "test", .label = "Method", .name = "z", .qualified_name = "test.beta.z"}, + {.project = "test", .label = "Module", .name = "gamma", .qualified_name = "test.gamma"}, + }; + int64_t ids[6]; + for (int i = 0; i < 6; i++) { + ids[i] = cbm_store_upsert_node(store, &nodes[i]); + } + /* Cross-folder call alpha.x -> beta.z should aggregate to a super-edge. */ + cbm_edge_t e = {.project = "test", .source_id = ids[1], .target_id = ids[4], .type = "CALLS"}; + cbm_store_insert_edge(store, &e); + + cbm_tree_view_t v; + ASSERT_EQ(cbm_store_expand_tree(store, "test", "test", 500, 2000, &v), CBM_STORE_OK); + + /* Three top-level groups: alpha (3 nodes), beta (2), gamma (1, leaf). */ + ASSERT_EQ(v.child_count, 3); + int ai = -1, bi = -1, gi = -1; + for (int i = 0; i < v.child_count; i++) { + if (strcmp(v.children[i].name, "alpha") == 0) + ai = i; + else if (strcmp(v.children[i].name, "beta") == 0) + bi = i; + else if (strcmp(v.children[i].name, "gamma") == 0) + gi = i; + } + ASSERT_GTE(ai, 0); + ASSERT_GTE(bi, 0); + ASSERT_GTE(gi, 0); + ASSERT_EQ(v.children[ai].count, 3); + ASSERT_TRUE(v.children[ai].expandable); + ASSERT_EQ(v.children[bi].count, 2); + ASSERT_EQ(v.children[gi].count, 1); + ASSERT_FALSE(v.children[gi].expandable); /* single leaf, nothing to drill into */ + + /* Exactly one aggregated super-edge: alpha -> beta, weight 1. */ + ASSERT_EQ(v.edge_count, 1); + ASSERT_EQ(v.edges[0].src, ai); + ASSERT_EQ(v.edges[0].dst, bi); + ASSERT_EQ(v.edges[0].weight, 1); + + cbm_tree_view_free(&v); + cbm_store_close(store); + PASS(); +} + +TEST(expand_tree_null_inputs) { + cbm_tree_view_t v; + ASSERT_EQ(cbm_store_expand_tree(NULL, "test", "test", 0, 0, &v), CBM_STORE_ERR); + cbm_tree_view_free(NULL); /* must not crash */ + PASS(); +} + /* ── Suite ────────────────────────────────────────────────────── */ SUITE(ui) { @@ -436,4 +509,8 @@ SUITE(ui) { RUN_TEST(layout_deterministic); RUN_TEST(layout_to_json); RUN_TEST(layout_null_inputs); + + /* Hierarchy expansion */ + RUN_TEST(expand_tree_groups_and_aggregates); + RUN_TEST(expand_tree_null_inputs); }