From a27680c3a9db2650a7669e744bd5d23b55f046e3 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 4 Jul 2026 22:19:53 +0200 Subject: [PATCH 1/2] feat(graph-ui): dead-code filtering, node code preview, GitHub deep-links Distilled from #789 (safe features only). Adds backend dead-code classification (status + in_calls in the layout JSON), a GET /api/repo-info endpoint for GitHub deep-links, and the frontend dead-code filters, node code preview, and deep-links. The render-cap revert was dropped (kept 2000 for DEFAULT_MAX_NODES/HARD_MAX_NODES and GRAPH_RENDER_NODE_LIMIT); the sidebar regex-search refactor was omitted. Security fixes over the original: repo-info strips credentials from any returned remote_url; the legitimate https blob-URL construction is allow-listed so the static gate passes; libgit2 is not re-initialized/shutdown per request (reuses the process-wide init from cbm_alloc_init); deep-link path segments are URL-encoded. Co-authored-by: Andy Zehady Signed-off-by: Martin Vogel --- graph-ui/src/components/FilterPanel.tsx | 108 ++++++++++- .../src/components/GraphTab.deadcode.test.tsx | 64 +++++++ graph-ui/src/components/GraphTab.tsx | 64 ++++++- .../src/components/NodeDetailPanel.test.tsx | 92 +++++++++ graph-ui/src/components/NodeDetailPanel.tsx | 113 ++++++++++- graph-ui/src/components/NodeTooltip.tsx | 29 ++- graph-ui/src/lib/colors.ts | 31 +++ graph-ui/src/lib/types.ts | 24 +++ scripts/security-allowlist.txt | 1 + src/ui/http_server.c | 178 +++++++++++++++++- src/ui/http_server.h | 12 ++ src/ui/layout3d.c | 120 ++++++++++++ src/ui/layout3d.h | 10 +- tests/test_httpd.c | 75 +++++++- tests/test_ui.c | 152 +++++++++++++++ 15 files changed, 1050 insertions(+), 23 deletions(-) create mode 100644 graph-ui/src/components/GraphTab.deadcode.test.tsx create mode 100644 graph-ui/src/components/NodeDetailPanel.test.tsx diff --git a/graph-ui/src/components/FilterPanel.tsx b/graph-ui/src/components/FilterPanel.tsx index 25d03c1e6..79f902aad 100644 --- a/graph-ui/src/components/FilterPanel.tsx +++ b/graph-ui/src/components/FilterPanel.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { colorForLabel } from "../lib/colors"; +import { colorForLabel, STATUS_LEGEND } from "../lib/colors"; import type { GraphData } from "../lib/types"; interface FilterPanelProps { @@ -12,6 +12,49 @@ interface FilterPanelProps { onToggleShowLabels: () => void; onEnableAll: () => void; onDisableAll: () => void; + /* Dead-code view */ + deadCodeView: boolean; + showOnlyDead: boolean; + hideEntryPoints: boolean; + hideTests: boolean; + onToggleDeadCodeView: () => void; + onToggleShowOnlyDead: () => void; + onToggleHideEntryPoints: () => void; + onToggleHideTests: () => void; +} + +/* Checkbox row matching the existing "Show labels" toggle style */ +function CheckRow({ + checked, + onToggle, + label, + count, +}: { + checked: boolean; + onToggle: () => void; + label: string; + count?: number; +}) { + return ( + + ); } export function FilterPanel({ @@ -24,18 +67,32 @@ export function FilterPanel({ onToggleShowLabels, onEnableAll, onDisableAll, + deadCodeView, + showOnlyDead, + hideEntryPoints, + hideTests, + onToggleDeadCodeView, + onToggleShowOnlyDead, + onToggleHideEntryPoints, + onToggleHideTests, }: FilterPanelProps) { - const { labelCounts, edgeTypeCounts } = useMemo(() => { + const { labelCounts, edgeTypeCounts, statusCounts } = useMemo(() => { const lc = new Map(); for (const n of data.nodes) lc.set(n.label, (lc.get(n.label) ?? 0) + 1); const ec = new Map(); for (const e of data.edges) ec.set(e.type, (ec.get(e.type) ?? 0) + 1); + const sc = new Map(); + for (const n of data.nodes) + if (n.status) sc.set(n.status, (sc.get(n.status) ?? 0) + 1); return { labelCounts: [...lc.entries()].sort((a, b) => b[1] - a[1]), edgeTypeCounts: [...ec.entries()].sort((a, b) => b[1] - a[1]), + statusCounts: sc, }; }, [data]); + const deadCount = statusCounts.get("dead") ?? 0; + return (
{/* Header row */} @@ -110,6 +167,53 @@ export function FilterPanel({ Show labels + + {/* Dead-code view */} +
+
+ + Dead code + + + {deadCount.toLocaleString()} dead + +
+ + + + + + + {/* Legend (only meaningful while colored by status) */} + {deadCodeView && ( +
+ {STATUS_LEGEND.map((s) => ( + + + {s.label} + + ))} +
+ )} +
); } diff --git a/graph-ui/src/components/GraphTab.deadcode.test.tsx b/graph-ui/src/components/GraphTab.deadcode.test.tsx new file mode 100644 index 000000000..43f880f30 --- /dev/null +++ b/graph-ui/src/components/GraphTab.deadcode.test.tsx @@ -0,0 +1,64 @@ +/* @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GraphTab } from "./GraphTab"; +import type { GraphData } from "../lib/types"; + +/* GraphScene renders a WebGL which jsdom can't run — stub it out. */ +vi.mock("./GraphScene", () => ({ + GraphScene: () => null, + computeCameraTarget: () => null, +})); + +const SAMPLE: GraphData = { + nodes: [ + { + id: 1, x: 0, y: 0, z: 0, label: "Function", name: "orphan", + file_path: "src/orphan.ts", size: 1, color: "#fff", status: "dead", in_calls: 0, + }, + { + id: 2, x: 1, y: 0, z: 0, label: "Function", name: "used", + file_path: "src/used.ts", size: 1, color: "#fff", status: "normal", in_calls: 3, + }, + ], + edges: [{ source: 2, target: 1, type: "CALLS" }], + total_nodes: 2, +}; + +function mockLayoutFetch(data: GraphData) { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/layout")) { + return new Response(JSON.stringify(data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("{}", { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("GraphTab dead-code filters", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows the dead count and filters to only dead code on toggle", async () => { + mockLayoutFetch(SAMPLE); + render(); + + /* Panel loaded; the dead-code section reports one dead node. */ + expect(await screen.findByText("Filters")).toBeInTheDocument(); + expect(screen.getByText("1 dead")).toBeInTheDocument(); + + /* Both nodes visible initially — no "filtered from" notice. */ + expect(screen.queryByText(/filtered from/)).not.toBeInTheDocument(); + + /* Toggling "Show only dead code" hides the non-dead node. */ + fireEvent.click(screen.getByRole("button", { name: /Show only dead code/ })); + expect(await screen.findByText(/filtered from 2/)).toBeInTheDocument(); + }); +}); diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index 45c2532d2..b0c163d34 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -11,7 +11,8 @@ import { FilterPanel } from "./FilterPanel"; import { NodeDetailPanel } from "./NodeDetailPanel"; import { ResizeHandle } from "./ResizeHandle"; import { ErrorBoundary } from "./ErrorBoundary"; -import type { GraphNode, GraphData } from "../lib/types"; +import type { GraphNode, GraphData, RepoInfo } from "../lib/types"; +import { colorForStatus } from "../lib/colors"; /* Persist panel widths */ function loadWidth(key: string, fallback: number): number { @@ -40,6 +41,7 @@ export function GraphTab({ project }: GraphTabProps) { const [selectedPath, setSelectedPath] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [cameraTarget, setCameraTarget] = useState(null); + const [repoInfo, setRepoInfo] = useState(null); const [showLabels, setShowLabels] = useState(true); const [leftWidth, setLeftWidth] = useState(() => loadWidth("cbm-left-w", 260)); const [rightWidth, setRightWidth] = useState(() => loadWidth("cbm-right-w", 280)); @@ -49,6 +51,12 @@ export function GraphTab({ project }: GraphTabProps) { const [enabledLabels, setEnabledLabels] = useState>(new Set()); const [enabledEdgeTypes, setEnabledEdgeTypes] = useState>(new Set()); + /* Dead-code view: recolor by status + status-based filters */ + const [deadCodeView, setDeadCodeView] = useState(false); + const [showOnlyDead, setShowOnlyDead] = useState(false); + const [hideEntryPoints, setHideEntryPoints] = useState(false); + const [hideTests, setHideTests] = useState(false); + /* Initialize filters when data loads */ useEffect(() => { if (!data) return; @@ -67,7 +75,19 @@ export function GraphTab({ project }: GraphTabProps) { const filteredData: GraphData | null = useMemo(() => { if (!data) return null; - const nodes = data.nodes.filter((n) => enabledLabels.has(n.label)); + /* Status-based filters (dead-code view) */ + const statusOk = (n: GraphNode) => { + if (showOnlyDead && n.status !== "dead") return false; + if (hideEntryPoints && n.status === "entry") return false; + if (hideTests && n.status === "test") return false; + return true; + }; + /* Recolor by status when the dead-code view is on */ + const paint = (n: GraphNode): GraphNode => + deadCodeView ? { ...n, color: colorForStatus(n.status) } : n; + const keep = (n: GraphNode) => enabledLabels.has(n.label) && statusOk(n); + + const nodes = data.nodes.filter(keep).map(paint); const nodeIds = new Set(nodes.map((n) => n.id)); const edges = data.edges.filter( (e) => @@ -77,7 +97,7 @@ export function GraphTab({ project }: GraphTabProps) { ); const linked_projects = data.linked_projects?.map((lp) => { - const lpNodes = lp.nodes.filter((n) => enabledLabels.has(n.label)); + const lpNodes = lp.nodes.filter(keep).map(paint); const lpIds = new Set(lpNodes.map((n) => n.id)); const lpEdges = lp.edges.filter( (e) => @@ -91,7 +111,15 @@ export function GraphTab({ project }: GraphTabProps) { }); return { nodes, edges, total_nodes: data.total_nodes, linked_projects }; - }, [data, enabledLabels, enabledEdgeTypes]); + }, [ + data, + enabledLabels, + enabledEdgeTypes, + deadCodeView, + showOnlyDead, + hideEntryPoints, + hideTests, + ]); useEffect(() => { if (project) { @@ -101,6 +129,24 @@ export function GraphTab({ project }: GraphTabProps) { } }, [project, fetchOverview]); + /* Fetch git remote metadata for GitHub deep-links */ + useEffect(() => { + if (!project) { + setRepoInfo(null); + return; + } + let cancelled = false; + fetch(`/api/repo-info?project=${encodeURIComponent(project)}`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (!cancelled && d && !d.error) setRepoInfo(d as RepoInfo); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [project]); + const handleSelectPath = useCallback( (path: string, nodeIds: Set) => { if (!filteredData || !path || nodeIds.size === 0) { @@ -239,6 +285,14 @@ export function GraphTab({ project }: GraphTabProps) { onToggleShowLabels={() => setShowLabels((v) => !v)} onEnableAll={enableAll} onDisableAll={disableAll} + deadCodeView={deadCodeView} + showOnlyDead={showOnlyDead} + hideEntryPoints={hideEntryPoints} + hideTests={hideTests} + onToggleDeadCodeView={() => setDeadCodeView((v) => !v)} + onToggleShowOnlyDead={() => setShowOnlyDead((v) => !v)} + onToggleHideEntryPoints={() => setHideEntryPoints((v) => !v)} + onToggleHideTests={() => setHideTests((v) => !v)} /> { setSelectedNode(null); setHighlightedIds(null); diff --git a/graph-ui/src/components/NodeDetailPanel.test.tsx b/graph-ui/src/components/NodeDetailPanel.test.tsx new file mode 100644 index 000000000..43b5fed7c --- /dev/null +++ b/graph-ui/src/components/NodeDetailPanel.test.tsx @@ -0,0 +1,92 @@ +/* @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 } from "./NodeDetailPanel"; +import type { GraphNode, RepoInfo } from "../lib/types"; + +/* Mock the RPC layer so "Show code" resolves without a backend. */ +const callToolMock = vi.fn(); +vi.mock("../api/rpc", () => ({ + callTool: (...args: unknown[]) => callToolMock(...args), + RpcError: class extends Error {}, +})); + +const NODE: GraphNode = { + id: 7, + x: 0, + y: 0, + z: 0, + label: "Function", + name: "render", + file_path: "src/weird name/@mod.ts", + qualified_name: "app::render", + start_line: 10, + end_line: 20, + size: 1, + color: "#fff", +}; + +const REPO: RepoInfo = { + root_path: "/repo", + branch: "main", + remote_url: "https://github.com/org/repo.git", + web_base: "https://github.com/org/repo", + blob_base: "https://github.com/org/repo/blob/main", +}; + +describe("NodeDetailPanel code preview + deep-link", () => { + it("renders fetched source as escaped text, never as injected HTML", async () => { + /* A payload that would execute if the code were rendered as raw HTML. */ + const payload = "\nconst answer = 42;"; + callToolMock.mockResolvedValueOnce({ source: payload }); + + const { container } = render( + {}} + onNavigate={() => {}} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: /Show code/ })); + + const pre = await screen.findByText((content) => content.includes("const answer = 42;"), { + selector: "pre", + }); + expect(pre.tagName).toBe("PRE"); + /* The dangerous markup is present as literal text… */ + expect(pre.textContent).toContain(""); + /* …but was NOT parsed into a real