Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ soak-results/

# LSP originality-check reference cache (scripts/check-lsp-originality.sh)
.lsp-refs/

# Generated by scripts/embed-frontend.sh during cbm-with-ui build
src/ui/embedded_assets.c
102 changes: 100 additions & 2 deletions graph-ui/src/components/FilterPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 (
<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 @@ -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<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="px-4 py-3 border-b border-border/40 space-y-3">
{/* Header row */}
Expand Down Expand Up @@ -110,6 +167,47 @@ export function FilterPanel({
</span>
Show labels
</button>

{/* Dead-code view */}
<div className="pt-2 border-t border-border/30 space-y-2">
<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>
</div>
);
}
67 changes: 62 additions & 5 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 @@ -234,7 +280,7 @@ export function GraphTab({ project }: GraphTabProps) {
<div className="h-full flex">
{/* Left sidebar — resizable */}
<div
className="border-r border-border/30 flex flex-col h-full bg-[#0b1920]/90 backdrop-blur-md shrink-0"
className="border-r border-border/30 flex flex-col h-full overflow-y-auto bg-[#0b1920]/90 backdrop-blur-md shrink-0"
style={{ width: leftWidth }}
>
<FilterPanel
Expand All @@ -247,11 +293,20 @@ 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}
onSelectPath={handleSelectPath}
selectedPath={selectedPath}
inline
/>
</div>
<ResizeHandle
Expand Down Expand Up @@ -349,6 +404,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