Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 106 additions & 2 deletions graph-ui/src/components/FilterPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { colorForLabel } from "../lib/colors";
import { colorForLabel, STATUS_LEGEND } from "../lib/colors";
import type { GraphData } from "../lib/types";

interface FilterPanelProps {
Expand All @@ -13,6 +13,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 (
<button
onClick={onToggle}
className={`flex items-center gap-1.5 text-[11px] font-medium transition-all ${
checked ? "text-primary" : "text-foreground/40"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-all ${
checked ? "border-primary bg-primary/20" : "border-foreground/15"
}`}
>
{checked && <span className="text-primary text-[9px]">✓</span>}
</span>
{label}
{count !== undefined && (
<span className="text-foreground/25 tabular-nums">{count.toLocaleString()}</span>
)}
</button>
);
}

export function FilterPanel({
Expand All @@ -25,18 +68,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<string, number>();
for (const n of data.nodes) lc.set(n.label, (lc.get(n.label) ?? 0) + 1);
const ec = new Map<string, number>();
for (const e of data.edges) ec.set(e.type, (ec.get(e.type) ?? 0) + 1);
const sc = new Map<string, number>();
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 (
<div className="flex flex-col shrink-0 max-h-[45%] border-b border-border/40">
{/* Header row — always visible */}
Expand Down Expand Up @@ -106,6 +163,53 @@ export function FilterPanel({
</div>
</ScrollArea>

{/* Dead-code view */}
<div className="px-4 pt-2 border-t border-border/30 space-y-2 shrink-0">
<div className="flex items-center justify-between">
<span className="text-[10px] text-foreground/30 uppercase tracking-widest">
Dead code
</span>
<span className="text-[10px] text-red-400/80 tabular-nums">
{deadCount.toLocaleString()} dead
</span>
</div>

<CheckRow
checked={deadCodeView}
onToggle={onToggleDeadCodeView}
label="Color by status"
/>
<CheckRow
checked={showOnlyDead}
onToggle={onToggleShowOnlyDead}
label="Show only dead code"
/>
<CheckRow
checked={hideEntryPoints}
onToggle={onToggleHideEntryPoints}
label="Hide entry points"
/>
<CheckRow checked={hideTests} onToggle={onToggleHideTests} label="Hide tests" />

{/* Legend (only meaningful while colored by status) */}
{deadCodeView && (
<div className="flex flex-wrap gap-x-2 gap-y-1 pt-1">
{STATUS_LEGEND.map((s) => (
<span
key={s.status}
className="inline-flex items-center gap-1 text-[9px] text-foreground/40"
>
<span
className="w-[6px] h-[6px] rounded-full"
style={{ backgroundColor: s.color }}
/>
{s.label}
</span>
))}
</div>
)}
</div>

{/* Display options — pinned footer */}
<div className="px-4 py-2.5 border-t border-border/20 shrink-0">
<button
Expand Down
64 changes: 64 additions & 0 deletions graph-ui/src/components/GraphTab.deadcode.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <Canvas> 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(<GraphTab project="demo" />);

/* 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();
});
});
64 changes: 60 additions & 4 deletions graph-ui/src/components/GraphTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -40,6 +41,7 @@ export function GraphTab({ project }: GraphTabProps) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [cameraTarget, setCameraTarget] = useState<CameraTarget | null>(null);
const [repoInfo, setRepoInfo] = useState<RepoInfo | null>(null);
const [showLabels, setShowLabels] = useState(true);
const [leftWidth, setLeftWidth] = useState(() => loadWidth("cbm-left-w", 260));
const [rightWidth, setRightWidth] = useState(() => loadWidth("cbm-right-w", 280));
Expand All @@ -49,6 +51,12 @@ export function GraphTab({ project }: GraphTabProps) {
const [enabledLabels, setEnabledLabels] = useState<Set<string>>(new Set());
const [enabledEdgeTypes, setEnabledEdgeTypes] = useState<Set<string>>(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;
Expand All @@ -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) =>
Expand All @@ -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) =>
Expand All @@ -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) {
Expand All @@ -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<number>) => {
if (!filteredData || !path || nodeIds.size === 0) {
Expand Down Expand Up @@ -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)}
/>
<Sidebar
nodes={filteredData.nodes}
Expand Down Expand Up @@ -354,6 +408,8 @@ export function GraphTab({ project }: GraphTabProps) {
node={selectedNode}
allNodes={filteredData.nodes}
allEdges={filteredData.edges}
project={project}
repoInfo={repoInfo}
onClose={() => {
setSelectedNode(null);
setHighlightedIds(null);
Expand Down
Loading
Loading