chore: import upstream snapshot with attribution
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:28:05 +08:00
commit 41cb1c0170
1830 changed files with 38276124 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+1
View File
@@ -0,0 +1 @@
{"$schema":"https://ui.shadcn.com/schema.json","style":"new-york","rsc":false,"tsx":true,"tailwind":{"config":"","css":"src/styles/globals.css","baseColor":"neutral","cssVariables":true},"aliases":{"components":"@/components","utils":"@/lib/utils","ui":"@/components/ui","lib":"@/lib","hooks":"@/hooks"},"iconLibrary":"lucide"}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codebase Memory — Graph</title>
</head>
<body class="bg-[#0a0a10] text-[#e4e4ed] min-h-screen">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6413
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "graph-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@react-three/drei": "^10.7.0",
"@react-three/fiber": "^9.5.0",
"@react-three/postprocessing": "^3.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"postprocessing": "^6.38.3",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0",
"three": "~0.183.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/three": "~0.183.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^25.0.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.7.0",
"vite": "^6.4.3",
"vitest": "^4.1.0"
},
"overrides": {
"form-data": ">=4.0.6",
"@babel/core": ">=7.29.6"
}
}
+135
View File
@@ -0,0 +1,135 @@
import { useCallback, useEffect, useState } from "react";
import { GraphTab } from "./components/GraphTab";
import { StatsTab } from "./components/StatsTab";
import { ControlTab } from "./components/ControlTab";
import type { TabId } from "./lib/types";
import { useUiMessages } from "./lib/i18n";
const TAB_IDS: TabId[] = ["graph", "stats", "control"];
interface RouteState {
tab: TabId;
project: string | null;
}
/* Read the active tab + selected project from the URL query string so the
* current view survives refreshes and can be bookmarked or shared. */
function readRoute(): RouteState {
const params = new URLSearchParams(window.location.search);
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 };
}
/* Build the canonical URL for a route, preserving the path and hash. */
function routeUrl(tab: TabId, project: string | null): string {
const params = new URLSearchParams();
params.set("tab", tab);
if (project) params.set("project", project);
return `${window.location.pathname}?${params.toString()}${window.location.hash}`;
}
export function App() {
const t = useUiMessages();
const [route, setRoute] = useState<RouteState>(readRoute);
const { tab: activeTab, project: selectedProject } = route;
/* 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));
}, []);
/* Sync state when the user navigates with the browser back/forward buttons. */
useEffect(() => {
const onPopState = () => setRoute(readRoute());
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
/* 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 tabs: { id: TabId; label: string }[] = [
{ id: "graph", label: t.tabs.graph },
{ id: "stats", label: t.tabs.projects },
{ id: "control", label: t.tabs.control },
];
return (
<div className="h-screen flex flex-col bg-background text-foreground">
{/* Header */}
<header className="flex items-center justify-between px-5 h-12 border-b border-border bg-[#0b1920]/80 backdrop-blur-md shrink-0">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2.5">
<div className="w-[7px] h-[7px] rounded-full bg-primary" />
<span className="text-[13px] font-semibold text-foreground/90 tracking-tight">
Codebase Memory
</span>
</div>
{/* Tabs inline in header */}
<nav className="flex items-center gap-0.5">
{tabs.map((tab) => {
const disabled = tab.id === "graph" && !selectedProject;
return (
<button
key={tab.id}
onClick={() => navigate(tab.id, tab.id === "stats" ? null : selectedProject)}
disabled={disabled}
title={disabled ? "Select a project first" : undefined}
className={`px-3 py-1 rounded-md text-[12px] font-medium transition-all ${
disabled
? "text-muted-foreground/30 cursor-not-allowed"
: activeTab === tab.id
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-white/[0.04]"
}`}
>
{tab.label}
</button>
);
})}
</nav>
</div>
{selectedProject && (
<div className="flex items-center gap-2 px-3 py-1 rounded-lg bg-white/[0.04] border border-border/30">
<span className="text-[10px] text-foreground/30 uppercase tracking-wider">
{t.graph.selectedLabel}
</span>
<span className="text-[11px] text-primary font-mono truncate max-w-[300px]">
{selectedProject}
</span>
<button
onClick={() => navigate("stats", null)}
className="text-foreground/20 hover:text-foreground/50 text-[12px] ml-1 transition-colors"
>
×
</button>
</div>
)}
</header>
{/* Content */}
<main className="flex-1 min-h-0">
{activeTab === "graph" ? (
<GraphTab project={selectedProject} />
) : activeTab === "control" ? (
<ControlTab />
) : (
<StatsTab
onSelectProject={(p) => navigate("graph", p)}
/>
)}
</main>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
/* JSON-RPC client — speaks the same protocol as MCP clients via POST /rpc */
let _nextId = 1;
export class RpcError extends Error {
constructor(
public code: number,
message: string,
) {
super(message);
this.name = "RpcError";
}
}
export async function callTool<T = unknown>(
name: string,
args: Record<string, unknown> = {},
): Promise<T> {
const res = await fetch("/rpc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: _nextId++,
method: "tools/call",
params: { name, arguments: args },
}),
});
if (!res.ok) {
throw new RpcError(-1, `HTTP ${res.status}: ${res.statusText}`);
}
const json = await res.json();
if (json.error) {
throw new RpcError(json.error.code ?? -1, json.error.message ?? "unknown");
}
/* MCP tool results are wrapped: { result: { content: [{ text: "..." }] } } */
const text = json?.result?.content?.[0]?.text;
if (text === undefined) {
return json.result as T;
}
return JSON.parse(text) as T;
}
+227
View File
@@ -0,0 +1,227 @@
import { useState, useEffect, useCallback } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { ProcessInfo } from "../lib/types";
import { useUiMessages } from "../lib/i18n";
/* ── Gauge component ────────────────────────────────────── */
function Gauge({ label, value, max, unit, color }: {
label: string; value: number; max: number; unit: string; color: string;
}) {
const pct = Math.min(100, (value / max) * 100);
return (
<div className="flex-1 rounded-xl border border-border/30 bg-white/[0.02] p-4">
<p className="text-[10px] text-foreground/25 uppercase tracking-widest mb-2">{label}</p>
<p className={`text-[20px] font-semibold tabular-nums ${color}`}>
{value.toFixed(1)}<span className="text-[11px] text-foreground/30 ml-1">{unit}</span>
</p>
<div className="mt-2 h-1.5 rounded-full bg-white/[0.05] overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: pct > 80 ? "#e05252" : pct > 50 ? "#eab308" : "#1DA27E" }}
/>
</div>
</div>
);
}
/* ── Process card ───────────────────────────────────────── */
function ProcessCard({ proc, selected, onSelect, onKill }: {
proc: ProcessInfo; selected: boolean;
onSelect: () => void; onKill: () => void;
}) {
const t = useUiMessages();
return (
<button
onClick={onSelect}
className={`w-full text-left rounded-xl border p-4 transition-all ${
selected
? "border-primary/40 bg-primary/5"
: "border-border/30 bg-white/[0.02] hover:bg-white/[0.04]"
}`}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${proc.is_self ? "bg-primary animate-pulse" : "bg-emerald-400"}`} />
<span className="text-[12px] font-semibold text-foreground/80">
PID {proc.pid}
</span>
{proc.is_self && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-primary/15 text-primary font-medium">{t.control.thisProcess}</span>
)}
</div>
{!proc.is_self && (
<button
onClick={(e) => { e.stopPropagation(); onKill(); }}
className="px-2 py-1 rounded-lg text-[10px] text-foreground/20 hover:text-destructive hover:bg-destructive/10 transition-all"
>
{t.control.kill}
</button>
)}
</div>
<div className="grid grid-cols-3 gap-3 mb-2">
<div>
<p className="text-[9px] text-foreground/20 uppercase">CPU</p>
<p className="text-[13px] font-semibold tabular-nums text-foreground/70">{proc.cpu.toFixed(1)}%</p>
</div>
<div>
<p className="text-[9px] text-foreground/20 uppercase">RAM</p>
<p className="text-[13px] font-semibold tabular-nums text-foreground/70">{proc.rss_mb.toFixed(0)} MB</p>
</div>
<div>
<p className="text-[9px] text-foreground/20 uppercase">{t.control.uptime}</p>
<p className="text-[13px] font-semibold tabular-nums text-foreground/70">{proc.elapsed}</p>
</div>
</div>
<p className="text-[10px] text-foreground/15 font-mono truncate">{proc.command}</p>
</button>
);
}
/* ── Log viewer ─────────────────────────────────────────── */
function LogViewer() {
const t = useUiMessages();
const [lines, setLines] = useState<string[]>([]);
useEffect(() => {
const poll = setInterval(async () => {
try {
const res = await fetch("/api/logs?lines=200");
const data = await res.json();
setLines(data.lines ?? []);
} catch { /* ignore */ }
}, 2000);
/* Initial fetch */
fetch("/api/logs?lines=200").then(r => r.json()).then(d => setLines(d.lines ?? [])).catch(() => {});
return () => clearInterval(poll);
}, []);
return (
<div className="rounded-xl border border-border/30 bg-black/30 overflow-hidden">
<div className="px-4 py-2 border-b border-border/20">
<span className="text-[11px] font-medium text-foreground/40">{t.control.processLogs}</span>
<span className="text-[10px] text-foreground/15 ml-2">{lines.length} lines</span>
</div>
<ScrollArea className="h-[400px]">
<div className="p-3 font-mono text-[10px] leading-relaxed">
{lines.length === 0 ? (
<p className="text-foreground/15 text-center py-8">{t.control.noLogs}</p>
) : (
lines.map((line, i) => {
const isErr = line.includes("level=error");
const isWarn = line.includes("level=warn");
return (
<div
key={i}
className={`py-[1px] ${
isErr ? "text-red-400/70" : isWarn ? "text-yellow-400/60" : "text-foreground/30"
}`}
>
{line}
</div>
);
})
)}
</div>
</ScrollArea>
</div>
);
}
/* ── Main Control Tab ───────────────────────────────────── */
export function ControlTab() {
const t = useUiMessages();
const [processes, setProcesses] = useState<ProcessInfo[]>([]);
const [selfMetrics, setSelfMetrics] = useState({ rss_mb: 0, user_cpu: 0, sys_cpu: 0 });
const [selectedPid, setSelectedPid] = useState<number | null>(null);
const fetchProcesses = useCallback(async () => {
try {
const res = await fetch("/api/processes");
const data = await res.json();
setProcesses(data.processes ?? []);
setSelfMetrics({
rss_mb: data.self_rss_mb ?? 0,
user_cpu: data.self_user_cpu_s ?? 0,
sys_cpu: data.self_sys_cpu_s ?? 0,
});
} catch { /* ignore */ }
}, []);
useEffect(() => {
fetchProcesses();
const interval = setInterval(fetchProcesses, 3000);
return () => clearInterval(interval);
}, [fetchProcesses]);
const killProcess = useCallback(async (pid: number) => {
if (!confirm(t.control.killConfirm(pid))) return;
try {
await fetch("/api/process-kill", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pid }),
});
setTimeout(fetchProcesses, 1000);
} catch { /* ignore */ }
}, [fetchProcesses, t.control]);
/* Aggregates */
const totalCpu = processes.reduce((s, p) => s + p.cpu, 0);
const totalRam = processes.reduce((s, p) => s + p.rss_mb, 0);
return (
<ScrollArea className="h-full">
<div className="p-8 max-w-4xl mx-auto">
<h2 className="text-[15px] font-semibold text-foreground/80 mb-6">{t.control.panel}</h2>
{/* Aggregate gauges */}
<div className="flex gap-4 mb-8">
<Gauge label={t.control.totalCpu} value={totalCpu} max={100 * processes.length || 100} unit="%" color="text-foreground/80" />
<Gauge label={t.control.totalRam} value={totalRam} max={4096} unit="MB" color="text-foreground/80" />
<Gauge label={t.control.processes} value={processes.length} max={10} unit="" color="text-primary" />
<Gauge label={t.control.selfRam} value={selfMetrics.rss_mb} max={2048} unit="MB" color="text-primary" />
</div>
{/* Process grid */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-[13px] font-medium text-foreground/50">
{t.control.activeProcesses}
</h3>
<button
onClick={fetchProcesses}
className="text-[11px] text-primary/60 hover:text-primary transition-colors"
>
{t.common.refresh}
</button>
</div>
{processes.length === 0 ? (
<p className="text-foreground/20 text-[12px] text-center py-8">{t.control.noProcesses}</p>
) : (
<div className="grid grid-cols-2 gap-3">
{processes.map((p) => (
<ProcessCard
key={p.pid}
proc={p}
selected={selectedPid === p.pid}
onSelect={() => setSelectedPid(selectedPid === p.pid ? null : p.pid)}
onKill={() => killProcess(p.pid)}
/>
))}
</div>
)}
</div>
{/* Log viewer */}
<LogViewer />
</div>
</ScrollArea>
);
}
@@ -0,0 +1,149 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DEFAULT_DISPLAY_SETTINGS,
DISPLAY_LIMITS,
type DisplaySettings,
} from "../lib/density";
interface DisplaySettingsMenuProps {
settings: DisplaySettings;
onChange: (next: DisplaySettings) => void;
}
interface SliderRowProps {
label: string;
hint: string;
value: number;
min: number;
max: number;
onChange: (value: number) => void;
}
function SliderRow({ label, hint, value, min, max, onChange }: SliderRowProps) {
return (
<label className="block">
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-foreground/70">{label}</span>
<span className="text-[10px] font-mono text-cyan-300/70 tabular-nums">
{value.toFixed(2)}×
</span>
</div>
<input
type="range"
min={min}
max={max}
step={0.05}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-full accent-cyan-400 cursor-pointer"
aria-label={`${label} (${hint})`}
/>
<p className="text-[9px] text-foreground/30 mt-0.5">{hint}</p>
</label>
);
}
/* Contrast / brightness controls for the 3D graph. These ride on top of the
* automatic density compensation — the defaults already adapt to graph size,
* so 1.00× is "auto"; the sliders let the user push it. */
export function DisplaySettingsMenu({
settings,
onChange,
}: DisplaySettingsMenuProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
/* Close on outside click / Escape */
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const set = (patch: Partial<DisplaySettings>) =>
onChange({ ...settings, ...patch });
const isDefault =
settings.edgeBrightness === DEFAULT_DISPLAY_SETTINGS.edgeBrightness &&
settings.nodeGlow === DEFAULT_DISPLAY_SETTINGS.nodeGlow &&
settings.bloom === DEFAULT_DISPLAY_SETTINGS.bloom;
return (
<div ref={rootRef} className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="dialog"
title="Contrast & brightness"
>
Display{!isDefault && <span className="ml-1 text-cyan-300"></span>}
</Button>
{open && (
<div
role="dialog"
aria-label="Display settings"
className="absolute top-10 right-0 w-64 p-4 rounded-lg border border-border/60 bg-[#0b1920]/95 backdrop-blur-md shadow-xl z-20 space-y-3.5"
>
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-foreground/50 uppercase tracking-widest">
Contrast
</span>
<button
onClick={() => onChange(DEFAULT_DISPLAY_SETTINGS)}
className="text-[10px] text-primary/70 hover:text-primary transition-colors disabled:opacity-30"
disabled={isDefault}
>
Reset
</button>
</div>
<SliderRow
label="Edge brightness"
hint="Dim the web of links on dense graphs"
value={settings.edgeBrightness}
min={DISPLAY_LIMITS.edgeBrightness.min}
max={DISPLAY_LIMITS.edgeBrightness.max}
onChange={(edgeBrightness) => set({ edgeBrightness })}
/>
<SliderRow
label="Node glow"
hint="Halo boost around each node"
value={settings.nodeGlow}
min={DISPLAY_LIMITS.nodeGlow.min}
max={DISPLAY_LIMITS.nodeGlow.max}
onChange={(nodeGlow) => set({ nodeGlow })}
/>
<SliderRow
label="Bloom"
hint="Overall glow bloom strength"
value={settings.bloom}
min={DISPLAY_LIMITS.bloom.min}
max={DISPLAY_LIMITS.bloom.max}
onChange={(bloom) => set({ bloom })}
/>
<p className="text-[9px] text-foreground/30 pt-1 border-t border-border/30">
1.00× follows the automatic density compensation. Lower the
edge/glow/bloom values when a large graph washes out to white.
</p>
</div>
)}
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useMemo } from "react";
import * as THREE from "three";
import type { GraphNode, GraphEdge } from "../lib/types";
import { edgeIntensityScale } from "../lib/density";
interface EdgeLinesProps {
nodes: GraphNode[];
edges: GraphEdge[];
highlightedIds: Set<number> | null;
opacity?: number;
/* User edge-brightness multiplier (see DisplaySettings). Layered on top of
* the automatic density scale. */
brightness?: number;
/* When set, edge.target is looked up in this array instead of `nodes`.
* Used for cross-galaxy edges where source lives in the primary graph
* and target lives in a linked project's offset-adjusted nodes. */
targetNodes?: GraphNode[];
}
function getClusterKey(fp?: string): string {
if (!fp) return "";
const parts = fp.split("/");
return parts.slice(0, Math.min(2, parts.length)).join("/");
}
/* Edge type → color (matches the filter panel) */
const EDGE_TYPE_COLORS: Record<string, string> = {
CALLS: "#1DA27E",
IMPORTS: "#3b82f6",
DEFINES: "#a855f7",
DEFINES_METHOD: "#a855f7",
CONTAINS_FILE: "#22c55e",
CONTAINS_FOLDER: "#22c55e",
CONTAINS_PACKAGE: "#22c55e",
HANDLES: "#eab308",
IMPLEMENTS: "#f97316",
HTTP_CALLS: "#e11d48",
ASYNC_CALLS: "#ec4899",
GRPC_CALLS: "#f59e0b",
GRAPHQL_CALLS: "#e879f9",
TRPC_CALLS: "#a78bfa",
CROSS_HTTP_CALLS: "#fb923c",
CROSS_ASYNC_CALLS: "#fb7185",
CROSS_GRPC_CALLS: "#fbbf24",
CROSS_GRAPHQL_CALLS: "#f0abfc",
CROSS_TRPC_CALLS: "#c4b5fd",
CROSS_CHANNEL: "#fdba74",
MEMBER_OF: "#64748b",
TESTS_FILE: "#06b6d4",
};
const DEFAULT_EDGE_COLOR = "#1C8585";
export function EdgeLines({
nodes,
edges,
highlightedIds,
opacity = 1.0,
brightness = 1.0,
targetNodes,
}: EdgeLinesProps) {
const geometry = useMemo(() => {
/* Shrink per-edge glow as the edge count grows so the additively-blended
* center doesn't saturate to white; the user multiplier rides on top. */
const densityScale = edgeIntensityScale(edges.length) * brightness;
const srcMap = new Map<number, number>();
for (let i = 0; i < nodes.length; i++) {
srcMap.set(nodes[i].id, i);
}
const tgtArr = targetNodes ?? nodes;
const tgtMap = targetNodes ? new Map<number, number>() : srcMap;
if (targetNodes) {
for (let i = 0; i < targetNodes.length; i++) {
tgtMap.set(targetNodes[i].id, i);
}
}
const hasHighlight = highlightedIds && highlightedIds.size > 0;
const positions = new Float32Array(edges.length * 6);
const colors = new Float32Array(edges.length * 6);
let validCount = 0;
for (const edge of edges) {
const si = srcMap.get(edge.source);
const ti = tgtMap.get(edge.target);
if (si === undefined || ti === undefined) continue;
const s = nodes[si];
const t = tgtArr[ti];
const sHL = !hasHighlight || highlightedIds.has(s.id);
const tHL = !hasHighlight || highlightedIds.has(t.id);
if (hasHighlight && !sHL && !tHL) continue;
const sameCluster =
getClusterKey(s.file_path) === getClusterKey(t.file_path);
/* Intensity based on cluster membership and highlight.
* With additive blending + dark background, these glow nicely. */
let intensity = sameCluster ? 0.25 : 0.06;
if (hasHighlight) {
/* A selection stays at full strength (never density-scaled) so it
* pops against the dimmed rest; only the un-selected bulk is scaled. */
intensity = sHL && tHL ? 0.5 : 0.04 * densityScale;
} else {
intensity *= densityScale;
}
const off = validCount * 6;
positions[off] = s.x;
positions[off + 1] = s.y;
positions[off + 2] = s.z;
positions[off + 3] = t.x;
positions[off + 4] = t.y;
positions[off + 5] = t.z;
/* Color from edge TYPE (correlates with edge type filter) */
const edgeColor = new THREE.Color(
EDGE_TYPE_COLORS[edge.type] ?? DEFAULT_EDGE_COLOR,
);
colors[off] = edgeColor.r * intensity;
colors[off + 1] = edgeColor.g * intensity;
colors[off + 2] = edgeColor.b * intensity;
colors[off + 3] = edgeColor.r * intensity;
colors[off + 4] = edgeColor.g * intensity;
colors[off + 5] = edgeColor.b * intensity;
validCount++;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute(
"position",
new THREE.BufferAttribute(positions.slice(0, validCount * 6), 3),
);
geo.setAttribute(
"color",
new THREE.BufferAttribute(colors.slice(0, validCount * 6), 3),
);
return geo;
}, [nodes, edges, highlightedIds, targetNodes, brightness]);
return (
<lineSegments geometry={geometry}>
<lineBasicMaterial
vertexColors
transparent
opacity={opacity}
blending={THREE.AdditiveBlending}
depthWrite={false}
toneMapped={false}
/>
</lineSegments>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Component } from "react";
import type { ReactNode, ErrorInfo } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("ErrorBoundary caught:", error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<div className="flex items-center justify-center h-full">
<div className="text-center p-8 max-w-md">
<p className="text-red-400 text-lg font-medium mb-2">
Rendering error
</p>
<p className="text-white/50 text-sm font-mono">
{this.state.error?.message ?? "Unknown error"}
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="mt-4 px-4 py-2 bg-white/10 hover:bg-white/20 rounded text-sm transition-colors"
>
Retry
</button>
</div>
</div>
)
);
}
return this.props.children;
}
}
+264
View File
@@ -0,0 +1,264 @@
import { useMemo } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { colorForLabel, STATUS_LEGEND } from "../lib/colors";
import type { GraphData } from "../lib/types";
interface FilterPanelProps {
data: GraphData;
enabledLabels: Set<string>;
enabledEdgeTypes: Set<string>;
showLabels: boolean;
onToggleLabel: (label: string) => void;
onToggleEdgeType: (type: string) => void;
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;
/* Missed skeleton (#963): white satellite of not-fully-indexed files */
missedView: boolean;
missedCount: number;
onToggleMissedView: () => 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({
data,
enabledLabels,
enabledEdgeTypes,
showLabels,
onToggleLabel,
onToggleEdgeType,
onToggleShowLabels,
onEnableAll,
onDisableAll,
deadCodeView,
showOnlyDead,
hideEntryPoints,
hideTests,
onToggleDeadCodeView,
onToggleShowOnlyDead,
onToggleHideEntryPoints,
onToggleHideTests,
missedView,
missedCount,
onToggleMissedView,
}: FilterPanelProps) {
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 */}
<div className="flex items-center justify-between px-4 pt-3 pb-2 shrink-0">
<span className="text-[11px] font-medium text-foreground/50 uppercase tracking-widest">
Filters
</span>
<div className="flex items-center gap-2">
<button onClick={onEnableAll} className="text-[10px] text-primary/70 hover:text-primary transition-colors">All</button>
<span className="text-foreground/15">|</span>
<button onClick={onDisableAll} className="text-[10px] text-primary/70 hover:text-primary transition-colors">None</button>
</div>
</div>
{/* Scrollable filter groups */}
<ScrollArea className="flex-1 min-h-0">
<div className="px-4 pb-3 space-y-3">
{/* Node types */}
{labelCounts.length > 0 && (
<div>
<p className="text-[10px] font-medium text-foreground/40 mb-1.5 uppercase tracking-wider">Node types</p>
<div className="flex flex-wrap gap-1">
{labelCounts.map(([label, count]) => {
const on = enabledLabels.has(label);
const c = colorForLabel(label);
return (
<button
key={label}
onClick={() => onToggleLabel(label)}
className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[10px] font-medium transition-all border ${
on ? "border-white/[0.08] bg-white/[0.04]" : "border-transparent opacity-25"
}`}
>
<span className="w-[5px] h-[5px] rounded-full" style={{ backgroundColor: on ? c : "#444" }} />
<span style={{ color: on ? c : "#555" }}>{label}</span>
<span className="text-foreground/20 tabular-nums">{count.toLocaleString()}</span>
</button>
);
})}
</div>
</div>
)}
{/* Relationships */}
{edgeTypeCounts.length > 0 && (
<div>
<p className="text-[10px] font-medium text-foreground/40 mb-1.5 uppercase tracking-wider">Relationships</p>
<div className="flex flex-wrap gap-1">
{edgeTypeCounts.map(([type, count]) => {
const on = enabledEdgeTypes.has(type);
return (
<button
key={type}
onClick={() => onToggleEdgeType(type)}
className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[10px] font-medium transition-all border ${
on ? "border-white/[0.06] bg-white/[0.03] text-foreground/60" : "border-transparent opacity-20 text-foreground/30"
}`}
>
{type.replace(/_/g, " ").toLowerCase()}
<span className="text-foreground/15 tabular-nums">{count.toLocaleString()}</span>
</button>
);
})}
</div>
</div>
)}
</div>
</ScrollArea>
{/* Missed skeleton (#963): white satellite cluster of files the indexer
could not fully cover, shown beside the code galaxy. Click it to
focus; click the code galaxy to come back. */}
<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">
Missed files
</span>
{missedCount > 0 && (
<span className="text-[10px] text-foreground/50 tabular-nums">
{missedCount.toLocaleString()} files
</span>
)}
</div>
<CheckRow
checked={missedView}
onToggle={onToggleMissedView}
label="Show missed skeleton"
/>
<p className="text-[9px] leading-snug text-foreground/30">
{missedCount > 0
? "White satellite = files not fully indexed (best-effort). Click it to focus, click the galaxy to return."
: "No known misses (best-effort — not a completeness guarantee)."}
</p>
</div>
{/* 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
onClick={onToggleShowLabels}
className={`inline-flex items-center gap-1.5 text-[11px] font-medium transition-all ${
showLabels ? "text-primary" : "text-foreground/30"
}`}
>
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-all ${
showLabels ? "border-primary bg-primary/20" : "border-foreground/15"
}`}>
{showLabels && <span className="text-primary text-[9px]"></span>}
</span>
Show labels
</button>
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import type { LoadProgress } from "../hooks/useGraphData";
/* Small constellation whose nodes pulse and whose edges draw themselves in a
* staggered loop — a miniature of the graph being loaded. Pure SVG/CSS
* (keyframes in globals.css) so it costs nothing while the real payload
* streams in, and it degrades to a static constellation under
* prefers-reduced-motion. */
const LOADER_NODES: Array<{ cx: number; cy: number; r: number; delay: string }> = [
{ cx: 60, cy: 18, r: 5, delay: "0s" },
{ cx: 22, cy: 42, r: 4, delay: "0.35s" },
{ cx: 98, cy: 40, r: 4, delay: "0.7s" },
{ cx: 42, cy: 78, r: 4.5, delay: "1.05s" },
{ cx: 84, cy: 82, r: 3.5, delay: "1.4s" },
{ cx: 60, cy: 52, r: 6, delay: "0.15s" },
{ cx: 14, cy: 88, r: 3, delay: "1.75s" },
];
const LOADER_EDGES: Array<{ x1: number; y1: number; x2: number; y2: number; delay: string }> = [
{ x1: 60, y1: 18, x2: 60, y2: 52, delay: "0s" },
{ x1: 22, y1: 42, x2: 60, y2: 52, delay: "0.3s" },
{ x1: 98, y1: 40, x2: 60, y2: 52, delay: "0.6s" },
{ x1: 42, y1: 78, x2: 60, y2: 52, delay: "0.9s" },
{ x1: 84, y1: 82, x2: 60, y2: 52, delay: "1.2s" },
{ x1: 42, y1: 78, x2: 14, y2: 88, delay: "1.5s" },
{ x1: 22, y1: 42, x2: 60, y2: 18, delay: "1.8s" },
];
function formatMegabytes(bytes: number): string {
return (bytes / (1024 * 1024)).toFixed(1);
}
interface GraphLoaderProps {
nodeBudget: number;
progress: LoadProgress;
}
export function GraphLoader({ nodeBudget, progress }: GraphLoaderProps) {
const receiving = progress.receivedBytes > 0;
return (
<div className="text-center" role="status" aria-live="polite">
<svg
width="120"
height="104"
viewBox="0 0 120 104"
className="mx-auto"
aria-hidden="true"
>
{LOADER_EDGES.map((e, i) => (
<line
key={`e${i}`}
x1={e.x1}
y1={e.y1}
x2={e.x2}
y2={e.y2}
className="graph-loader-edge"
style={{ animationDelay: e.delay }}
/>
))}
{LOADER_NODES.map((n, i) => (
<circle
key={`n${i}`}
cx={n.cx}
cy={n.cy}
r={n.r}
className="graph-loader-node"
style={{ animationDelay: n.delay }}
/>
))}
</svg>
<p className="text-white/50 text-sm mt-4">
{receiving ? "Receiving graph" : "Computing layout"} up to{" "}
{nodeBudget.toLocaleString("en-US")} nodes
</p>
<p className="text-cyan-300/60 text-xs font-mono mt-1 h-4">
{receiving
? progress.totalBytes
? `${formatMegabytes(progress.receivedBytes)} of ${formatMegabytes(progress.totalBytes)} MB`
: `${formatMegabytes(progress.receivedBytes)} MB received`
: " "}
</p>
</div>
);
}
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { GRAPH_CANVAS_DPR } from "./GraphScene";
describe("GraphScene render limits", () => {
it("caps the high-DPI WebGL backing store below the MSAA failure range", () => {
expect(GRAPH_CANVAS_DPR[0]).toBe(1);
expect(GRAPH_CANVAS_DPR[1]).toBeLessThanOrEqual(1.5);
});
});
+332
View File
@@ -0,0 +1,332 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Canvas, useThree, useFrame } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
import { EffectComposer, Bloom } from "@react-three/postprocessing";
import * as THREE from "three";
import { NodeCloud } from "./NodeCloud";
import { EdgeLines } from "./EdgeLines";
import { NodeLabels } from "./NodeLabels";
import { NodeTooltip } from "./NodeTooltip";
import type { GraphData, GraphNode, LinkedProject } from "../lib/types";
import {
DEFAULT_DISPLAY_SETTINGS,
bloomIntensityScale,
nodeBoostScale,
type DisplaySettings,
} from "../lib/density";
const BASE_BLOOM_INTENSITY = 1.45;
/* ── Camera fly-to animation ────────────────────────────── */
interface CameraTarget {
position: THREE.Vector3;
lookAt: THREE.Vector3;
}
function CameraAnimator({
target,
controlsRef,
}: {
target: CameraTarget | null;
controlsRef: React.RefObject<OrbitControlsImpl | null>;
}) {
const { camera } = useThree();
const targetRef = useRef<CameraTarget | null>(null);
const progress = useRef(1);
useEffect(() => {
if (target) {
targetRef.current = target;
progress.current = 0;
}
}, [target]);
useFrame(() => {
if (!targetRef.current || progress.current >= 1) return;
progress.current = Math.min(1, progress.current + 0.02);
const t = 1 - Math.pow(1 - progress.current, 3); /* ease-out cubic */
camera.position.lerp(targetRef.current.position, t * 0.08);
/* Move the OrbitControls pivot to the focus point as well. Otherwise the
* controls keep their target at the origin and re-center the view on the
* next frame, snapping the camera back to the middle after the fly-to. */
const controls = controlsRef.current;
if (controls) {
controls.target.lerp(targetRef.current.lookAt, t * 0.08);
controls.update();
} else {
camera.lookAt(targetRef.current.lookAt);
}
});
return null;
}
/* ── Idle auto-rotation ──────────────────────────────────── */
const IDLE_TIMEOUT_MS = 60_000;
export const GRAPH_CANVAS_DPR: [number, number] = [1, 1.5];
export const GRAPH_COMPOSER_MULTISAMPLING = 0;
function IdleAutoRotate({
controlsRef,
}: {
controlsRef: React.RefObject<OrbitControlsImpl | null>;
}) {
const lastInteraction = useRef(Date.now());
/* Reset timer on any pointer/wheel event */
const resetTimer = useCallback(() => {
lastInteraction.current = Date.now();
if (controlsRef.current) {
controlsRef.current.autoRotate = false;
}
}, [controlsRef]);
useEffect(() => {
const canvas = document.querySelector("canvas");
if (!canvas) return;
canvas.addEventListener("pointerdown", resetTimer);
canvas.addEventListener("wheel", resetTimer);
return () => {
canvas.removeEventListener("pointerdown", resetTimer);
canvas.removeEventListener("wheel", resetTimer);
};
}, [resetTimer]);
useFrame(() => {
if (!controlsRef.current) return;
const idle = Date.now() - lastInteraction.current > IDLE_TIMEOUT_MS;
controlsRef.current.autoRotate = idle;
});
return null;
}
/* ── Main scene ─────────────────────────────────────────── */
interface GraphSceneProps {
data: GraphData;
/* Missed skeleton (#963): pre-offset, pre-painted white nodes + edges of
* the not-fully-indexed files, rendered as a ghost cluster beside the
* galaxy. null hides it. */
missed?: { nodes: GraphNode[]; edges: GraphData["edges"] } | null;
highlightedIds: Set<number> | null;
cameraTarget: CameraTarget | null;
showLabels: boolean;
display?: DisplaySettings;
onNodeClick: (node: GraphNode) => void;
/* Fired when a click hits empty space (no node). Used to fly back to the
* overview after focusing the missed skeleton. */
onBackgroundClick?: () => void;
}
export type { CameraTarget };
export function GraphScene({
data,
missed = null,
highlightedIds,
cameraTarget,
showLabels,
display = DEFAULT_DISPLAY_SETTINGS,
onNodeClick,
onBackgroundClick,
}: GraphSceneProps) {
const [hovered, setHovered] = useState<GraphNode | null>(null);
const controlsRef = useRef<OrbitControlsImpl | null>(null);
/* Adaptive density defaults × user multipliers. The automatic scale keeps
* contrast roughly constant as the graph grows; the sliders nudge it.
* NodeCloud applies `nodeBoost` directly (no internal density scaling),
* whereas EdgeLines scales by edge density itself — so it receives only the
* user edge-brightness multiplier to avoid double-applying. */
const nodeBoost = nodeBoostScale(data.nodes.length) * display.nodeGlow;
const bloomIntensity =
BASE_BLOOM_INTENSITY * bloomIntensityScale(data.nodes.length) * display.bloom;
return (
<Canvas
camera={{ position: [0, 0, 800], fov: 50, near: 0.1, far: 100000 }}
style={{ background: "#06090f" }}
dpr={GRAPH_CANVAS_DPR}
gl={{
antialias: false,
alpha: false,
powerPreference: "high-performance",
}}
onPointerMissed={onBackgroundClick}
>
<color attach="background" args={["#06090f"]} />
<ambientLight intensity={0.5} />
<pointLight position={[500, 500, 500]} intensity={0.6} />
<pointLight
position={[-300, -200, -300]}
intensity={0.4}
color="#6040ff"
/>
<EdgeLines
nodes={data.nodes}
edges={data.edges}
highlightedIds={highlightedIds}
brightness={display.edgeBrightness}
/>
<NodeCloud
nodes={data.nodes}
highlightedIds={highlightedIds}
onHover={setHovered}
onClick={onNodeClick}
boost={nodeBoost}
/>
{showLabels && <NodeLabels nodes={data.nodes} highlightedIds={highlightedIds} />}
{/* Missed skeleton (#963): white ghost of the not-fully-indexed files.
* Clicks route through the same handler — GraphTab re-centers the
* camera on the whole skeleton cluster. */}
{missed && missed.nodes.length > 0 && (
<group>
<EdgeLines
nodes={missed.nodes}
edges={missed.edges}
highlightedIds={null}
opacity={0.28}
brightness={display.edgeBrightness}
/>
<NodeCloud
nodes={missed.nodes}
highlightedIds={null}
onHover={setHovered}
onClick={onNodeClick}
opacity={0.6}
boost={nodeBoost * 0.75}
/>
{showLabels && <NodeLabels nodes={missed.nodes} highlightedIds={null} />}
</group>
)}
{/* Satellite galaxies for cross-repo linked projects */}
{data.linked_projects?.map((lp: LinkedProject) => {
const offsetNodes = lp.nodes.map((n) => ({
...n,
x: n.x + lp.offset.x,
y: n.y + lp.offset.y,
z: n.z + lp.offset.z,
}));
return (
<group key={lp.project}>
<EdgeLines
nodes={offsetNodes}
edges={lp.edges}
highlightedIds={null}
opacity={0.3}
brightness={display.edgeBrightness}
/>
<NodeCloud
nodes={offsetNodes}
highlightedIds={null}
onHover={setHovered}
onClick={onNodeClick}
opacity={0.5}
boost={nodeBoost}
/>
{/* Inter-galaxy CROSS_* edges: source is in primary, target in
* this linked project's offset nodes. */}
{lp.cross_edges && lp.cross_edges.length > 0 && (
<EdgeLines
nodes={data.nodes}
targetNodes={offsetNodes}
edges={lp.cross_edges}
highlightedIds={highlightedIds}
opacity={0.85}
brightness={display.edgeBrightness}
/>
)}
</group>
);
})}
{hovered && <NodeTooltip node={hovered} />}
<CameraAnimator target={cameraTarget} controlsRef={controlsRef} />
<IdleAutoRotate controlsRef={controlsRef} />
<EffectComposer multisampling={GRAPH_COMPOSER_MULTISAMPLING}>
<Bloom
luminanceThreshold={0.3}
luminanceSmoothing={0.7}
intensity={bloomIntensity}
mipmapBlur
radius={0.6}
/>
</EffectComposer>
<OrbitControls
ref={controlsRef}
enableDamping
dampingFactor={0.08}
rotateSpeed={0.5}
zoomSpeed={1.5}
minDistance={10}
maxDistance={50000}
autoRotateSpeed={0.4}
/>
</Canvas>
);
}
/* ── Helper: compute camera target from node IDs ────────── */
export function computeCameraTarget(
nodes: GraphNode[],
ids: Set<number>,
): CameraTarget | null {
if (ids.size === 0) return null;
let cx = 0,
cy = 0,
cz = 0,
count = 0;
for (const node of nodes) {
if (ids.has(node.id)) {
cx += node.x;
cy += node.y;
cz += node.z;
count++;
}
}
if (count === 0) return null;
cx /= count;
cy /= count;
cz /= count;
/* Distance based on cluster spread — ensure we never zoom too close */
let maxDist = 0;
for (const node of nodes) {
if (ids.has(node.id)) {
const d = Math.sqrt(
(node.x - cx) ** 2 + (node.y - cy) ** 2 + (node.z - cz) ** 2,
);
if (d > maxDist) maxDist = d;
}
}
/* Minimum distance scales with count: single node = 300, cluster = spread-based */
const spreadDist = maxDist * 3;
const minDist = count <= 5 ? 300 : 200;
const distance = Math.max(minDist, spreadDist);
const lookAt = new THREE.Vector3(cx, cy, cz);
const position = new THREE.Vector3(
cx + distance * 0.2,
cy + distance * 0.15,
cz + distance,
);
return { position, lookAt };
}
@@ -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();
});
});
@@ -0,0 +1,63 @@
/* @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: "foo", size: 1, color: "#fff" },
{ id: 2, x: 1, y: 0, z: 0, label: "Class", name: "Bar", size: 1, color: "#fff" },
],
edges: [{ source: 1, target: 2, 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 filters", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("keeps the filter sidebar visible when all nodes are filtered out", async () => {
mockLayoutFetch(SAMPLE);
render(<GraphTab project="demo" />);
/* Wait for the layout to load — the filter panel header appears. */
expect(await screen.findByText("Filters")).toBeInTheDocument();
/* Disable every filter via the "None" shortcut. */
fireEvent.click(screen.getByRole("button", { name: "None" }));
/* The graph area reports that everything is filtered out… */
expect(screen.getByText("All nodes filtered out")).toBeInTheDocument();
/* …but the filter sidebar must stay so the user can re-enable filters
instead of being forced to reset everything. */
expect(screen.getByText("Filters")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "None" })).toBeInTheDocument();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { formatGraphLimitNotice } from "./GraphTab";
import type { GraphData } from "../lib/types";
describe("formatGraphLimitNotice", () => {
it("reports when the graph response is truncated for render safety", () => {
const data = {
nodes: Array.from({ length: 2000 }, (_, id) => ({
id,
x: 0,
y: 0,
z: 0,
label: "Function",
name: `fn${id}`,
size: 1,
color: "#ffffff",
})),
edges: [],
total_nodes: 43729,
} satisfies GraphData;
expect(formatGraphLimitNotice(data)).toBe(
"Showing 2,000 of 43,729 nodes (0 edges). Raise the node budget or use filters.",
);
});
it("stays quiet when the full graph is rendered", () => {
const data = {
nodes: [],
edges: [],
total_nodes: 0,
} satisfies GraphData;
expect(formatGraphLimitNotice(data)).toBeNull();
});
});
+593
View File
@@ -0,0 +1,593 @@
import { useEffect, useState, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
useGraphData,
clampNodeBudget,
GRAPH_RENDER_NODE_LIMIT,
GRAPH_NODE_BUDGET_STEP,
GRAPH_NODE_BUDGET_MAX,
} from "../hooks/useGraphData";
import { GraphLoader } from "./GraphLoader";
import { DisplaySettingsMenu } from "./DisplaySettingsMenu";
import {
loadDisplaySettings,
saveDisplaySettings,
type DisplaySettings,
} from "../lib/density";
import {
GraphScene,
computeCameraTarget,
type CameraTarget,
} from "./GraphScene";
import { Sidebar } from "./Sidebar";
import { FilterPanel } from "./FilterPanel";
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 { colorForStatus } from "../lib/colors";
/* Persist panel widths */
function loadWidth(key: string, fallback: number): number {
try {
const v = localStorage.getItem(key);
if (v) return Math.max(150, Math.min(600, parseInt(v, 10)));
} catch { /* ignore */ }
return fallback;
}
function saveWidth(key: string, value: number) {
try { localStorage.setItem(key, String(Math.round(value))); } catch { /* ignore */ }
}
/* Persist the node budget per project */
function budgetKey(project: string): string {
return `cbm-node-budget:${project}`;
}
function loadNodeBudget(project: string): number {
try {
const v = localStorage.getItem(budgetKey(project));
if (v) return clampNodeBudget(parseInt(v, 10));
} catch { /* ignore */ }
return GRAPH_RENDER_NODE_LIMIT;
}
function saveNodeBudget(project: string, value: number) {
try { localStorage.setItem(budgetKey(project), String(value)); } catch { /* ignore */ }
}
interface GraphTabProps {
project: string | null;
}
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) {
const { data, loading, error, progress, fetchOverview } = useGraphData();
const [highlightedIds, setHighlightedIds] = useState<Set<number> | null>(null);
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 [display, setDisplay] = useState<DisplaySettings>(() =>
loadDisplaySettings(),
);
const updateDisplay = useCallback((next: DisplaySettings) => {
setDisplay(next);
saveDisplaySettings(next);
}, []);
const [leftWidth, setLeftWidth] = useState(() => loadWidth("cbm-left-w", 260));
const [rightWidth, setRightWidth] = useState(() => loadWidth("cbm-right-w", 280));
const limitNotice = formatGraphLimitNotice(data);
/* Node budget — keyed to its project so switching projects re-reads the
* persisted value and triggers exactly one fetch. */
const [budget, setBudget] = useState<{ project: string | null; value: number }>(
{ project: null, value: GRAPH_RENDER_NODE_LIMIT },
);
const [budgetDraft, setBudgetDraft] = useState(String(GRAPH_RENDER_NODE_LIMIT));
const commitBudget = useCallback(() => {
const parsed = clampNodeBudget(parseInt(budgetDraft, 10));
setBudgetDraft(String(parsed));
if (project && parsed !== budget.value) {
saveNodeBudget(project, parsed);
setBudget({ project, value: parsed });
}
}, [budgetDraft, project, budget.value]);
/* Filter state — all enabled by default */
const [enabledLabels, setEnabledLabels] = useState<Set<string>>(new Set());
const [enabledEdgeTypes, setEnabledEdgeTypes] = useState<Set<string>>(new Set());
/* Missed skeleton (#963): the file structure of files the indexer could
* not fully cover, shown as a white satellite cluster beside the code
* galaxy. Toggle only hides/shows it — the data rides along with every
* code-graph layout. */
const [showMissedSkeleton, setShowMissedSkeleton] = useState(true);
/* 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;
const labels = new Set(data.nodes.map((n) => n.label));
const types = new Set(data.edges.map((e) => e.type));
for (const lp of data.linked_projects ?? []) {
for (const n of lp.nodes) labels.add(n.label);
for (const e of lp.edges) types.add(e.type);
for (const e of lp.cross_edges) types.add(e.type);
}
setEnabledLabels(labels);
setEnabledEdgeTypes(types);
}, [data]);
/* Compute filtered data */
const filteredData: GraphData | null = useMemo(() => {
if (!data) return null;
/* 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) =>
enabledEdgeTypes.has(e.type) &&
nodeIds.has(e.source) &&
nodeIds.has(e.target),
);
const linked_projects = data.linked_projects?.map((lp) => {
const lpNodes = lp.nodes.filter(keep).map(paint);
const lpIds = new Set(lpNodes.map((n) => n.id));
const lpEdges = lp.edges.filter(
(e) =>
enabledEdgeTypes.has(e.type) && lpIds.has(e.source) && lpIds.has(e.target),
);
const crossEdges = lp.cross_edges.filter(
(e) =>
enabledEdgeTypes.has(e.type) && nodeIds.has(e.source) && lpIds.has(e.target),
);
return { ...lp, nodes: lpNodes, edges: lpEdges, cross_edges: crossEdges };
});
return { nodes, edges, total_nodes: data.total_nodes, linked_projects };
}, [
data,
enabledLabels,
enabledEdgeTypes,
deadCodeView,
showOnlyDead,
hideEntryPoints,
hideTests,
]);
/* Re-read the persisted budget when the project changes… */
useEffect(() => {
if (project) {
const value = loadNodeBudget(project);
setBudget({ project, value });
setBudgetDraft(String(value));
}
}, [project]);
/* …and fetch only once budget and project agree (one fetch per change). */
useEffect(() => {
if (project && budget.project === project) {
fetchOverview(project, budget.value);
setHighlightedIds(null);
setSelectedPath(null);
}
}, [project, budget, fetchOverview]);
/* 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(() => {
const mg = data?.missed_graph;
if (!mg || mg.nodes.length === 0) return null;
const nodes = mg.nodes.map((n) => ({
...n,
x: n.x + mg.offset.x,
y: n.y + mg.offset.y,
z: n.z + mg.offset.z,
color: "#e9eef5",
}));
return { nodes, edges: mg.edges, ids: new Set(nodes.map((n) => n.id)) };
}, [data]);
/* Overview framing: both clusters (galaxy + skeleton) in one shot. */
const overviewTarget = useMemo(() => {
if (!data) return null;
const all = missedSkeleton ? [...data.nodes, ...missedSkeleton.nodes] : data.nodes;
return computeCameraTarget(all, new Set(all.map((n) => n.id)));
}, [data, missedSkeleton]);
/* With a skeleton beside the galaxy, auto-frame BOTH clusters on load so
* the side-by-side composition is visible without manual zooming. */
useEffect(() => {
if (missedSkeleton && overviewTarget) {
setCameraTarget(overviewTarget);
}
}, [missedSkeleton, overviewTarget]);
/* Clicking empty space while the skeleton has focus flies back to the
* overview (the galaxy may be entirely off-screen at that point, so there
* is no code node to click). No-op during normal galaxy exploration. */
const handleBackgroundClick = useCallback(() => {
if (selectedNode && missedSkeleton?.ids.has(selectedNode.id) && overviewTarget) {
setSelectedNode(null);
setHighlightedIds(null);
setSelectedPath(null);
setCameraTarget(overviewTarget);
}
}, [selectedNode, missedSkeleton, overviewTarget]);
/* 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) {
setHighlightedIds(null);
setSelectedPath(null);
setCameraTarget(null);
return;
}
setSelectedPath(path);
setHighlightedIds(nodeIds);
setCameraTarget(computeCameraTarget(filteredData.nodes, nodeIds));
},
[filteredData],
);
const handleNodeClick = useCallback(
(node: GraphNode) => {
if (!filteredData) return;
/* Clicking the missed skeleton re-centers the camera on that whole
* cluster (it's small — the natural focus unit is the skeleton, not a
* single node); clicking any code node flies back to the code galaxy
* via the normal per-node focus below. */
if (missedSkeleton?.ids.has(node.id)) {
setSelectedNode(node);
setHighlightedIds(null);
setSelectedPath(node.file_path ?? null);
setCameraTarget(computeCameraTarget(missedSkeleton.nodes, missedSkeleton.ids));
return;
}
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);
if (edge.target === node.id) connectedIds.add(edge.source);
}
setHighlightedIds(connectedIds);
setSelectedPath(node.file_path ?? null);
setCameraTarget(computeCameraTarget(filteredData.nodes, connectedIds));
},
[filteredData, missedSkeleton],
);
const handleNavigateToNode = useCallback(
(node: GraphNode) => {
handleNodeClick(node);
},
[handleNodeClick],
);
const toggleLabel = useCallback((label: string) => {
setEnabledLabels((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}, []);
const toggleEdgeType = useCallback((type: string) => {
setEnabledEdgeTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
}, []);
const enableAll = useCallback(() => {
if (!data) return;
const labels = new Set(data.nodes.map((n) => n.label));
const types = new Set(data.edges.map((e) => e.type));
for (const lp of data.linked_projects ?? []) {
for (const n of lp.nodes) labels.add(n.label);
for (const e of lp.edges) types.add(e.type);
for (const e of lp.cross_edges) types.add(e.type);
}
setEnabledLabels(labels);
setEnabledEdgeTypes(types);
}, [data]);
const disableAll = useCallback(() => {
setEnabledLabels(new Set());
setEnabledEdgeTypes(new Set());
}, []);
if (!project) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-white/30 text-sm">
Select a project from the Projects tab
</p>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<GraphLoader nodeBudget={budget.value} progress={progress} />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center p-8">
<p className="text-red-400 text-sm mb-2">{error}</p>
<Button variant="outline" size="sm" onClick={() => fetchOverview(project)}>
Retry
</Button>
</div>
</div>
);
}
/* No data, or the project genuinely has no nodes — there are no filters to
interact with, so show a plain full-screen message. The "all filtered out"
case is handled inside the layout below so the filter sidebar stays put. */
if (!data || !filteredData || data.nodes.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-white/30 text-sm">No nodes in this project</p>
</div>
);
}
return (
<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"
style={{ width: leftWidth }}
>
<FilterPanel
data={data}
enabledLabels={enabledLabels}
enabledEdgeTypes={enabledEdgeTypes}
showLabels={showLabels}
onToggleLabel={toggleLabel}
onToggleEdgeType={toggleEdgeType}
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)}
missedView={showMissedSkeleton}
missedCount={data?.missed_graph?.nodes.filter((n) => n.label === "File").length ?? 0}
onToggleMissedView={() => setShowMissedSkeleton((v) => !v)}
/>
<Sidebar
nodes={filteredData.nodes}
onSelectPath={handleSelectPath}
selectedPath={selectedPath}
/>
</div>
<ResizeHandle
side="left"
onResize={(d) => {
setLeftWidth((w) => {
const nw = Math.max(150, Math.min(500, w + d));
saveWidth("cbm-left-w", nw);
return nw;
});
}}
/>
{/* Graph area */}
<div className="flex-1 relative overflow-hidden">
{filteredData.nodes.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-white/30 text-sm mb-3">All nodes filtered out</p>
<Button size="sm" onClick={enableAll}>
Reset Filters
</Button>
</div>
</div>
) : (
<>
<ErrorBoundary>
<GraphScene
data={filteredData}
missed={showMissedSkeleton ? missedSkeleton : null}
highlightedIds={highlightedIds}
cameraTarget={cameraTarget}
showLabels={showLabels}
display={display}
onNodeClick={handleNodeClick}
onBackgroundClick={handleBackgroundClick}
/>
</ErrorBoundary>
{/* HUD */}
<div className="absolute top-4 left-4 text-[11px] text-white/30 pointer-events-none font-mono">
<p>
{filteredData.nodes.length.toLocaleString()} nodes /{" "}
{filteredData.edges.length.toLocaleString()} edges
</p>
{data.nodes.length > filteredData.nodes.length && (
<p className="text-white/25 mt-0.5">
filtered from {data.nodes.length.toLocaleString()}
</p>
)}
{limitNotice && (
<p className="text-amber-300/80 mt-0.5">{limitNotice}</p>
)}
{highlightedIds && highlightedIds.size > 0 && (
<p className="text-cyan-400/50 mt-0.5">
{highlightedIds.size} selected
</p>
)}
</div>
<div className="absolute top-4 right-4 flex gap-2 items-center">
{highlightedIds && (
<Button
size="sm"
onClick={() => {
setHighlightedIds(null);
setSelectedPath(null);
setSelectedNode(null);
setCameraTarget(null);
}}
>
Clear selection
</Button>
)}
<div className="flex items-center gap-1.5 h-8 px-2 rounded-md border border-border/50 bg-[#0b1920]/80 backdrop-blur-sm">
<label
htmlFor="node-budget"
className="text-[10px] uppercase tracking-wider text-white/40"
>
Nodes
</label>
<input
id="node-budget"
type="number"
min={GRAPH_NODE_BUDGET_STEP}
max={GRAPH_NODE_BUDGET_MAX}
step={GRAPH_NODE_BUDGET_STEP}
value={budgetDraft}
onChange={(e) => setBudgetDraft(e.target.value)}
onBlur={commitBudget}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
className="w-24 bg-transparent text-right text-xs font-mono text-cyan-200/90 outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
aria-label="Node budget: how many nodes to load"
title="How many nodes to load (5,000 steps, edges between loaded nodes follow automatically)"
/>
</div>
<DisplaySettingsMenu settings={display} onChange={updateDisplay} />
<Button
variant="outline"
size="sm"
onClick={() => {
setHighlightedIds(null);
setSelectedPath(null);
setSelectedNode(null);
setCameraTarget(null);
fetchOverview(project, budget.value);
}}
>
Refresh
</Button>
</div>
</>
)}
</div>
{/* Right detail panel — resizable */}
{selectedNode && filteredData && (
<>
<ResizeHandle
side="right"
onResize={(d) => {
setRightWidth((w) => {
const nw = Math.max(200, Math.min(500, w + d));
saveWidth("cbm-right-w", nw);
return nw;
});
}}
/>
<div
className="border-l border-border shrink-0 h-full overflow-hidden"
style={{ width: rightWidth, maxHeight: "100%" }}
>
{missedSkeleton?.ids.has(selectedNode.id) ? (
/* Skeleton node: the standard panel (code snippet, callers) is
* meaningless for a not-fully-indexed file — show the coverage
* callout with its report-the-edge-case actions instead. */
<MissedCallout
node={selectedNode}
project={project}
onClose={() => {
setSelectedNode(null);
setHighlightedIds(null);
setSelectedPath(null);
}}
/>
) : (
<NodeDetailPanel
node={selectedNode}
allNodes={filteredData.nodes}
allEdges={filteredData.edges}
project={project}
repoInfo={repoInfo}
onClose={() => {
setSelectedNode(null);
setHighlightedIds(null);
setSelectedPath(null);
}}
onNavigate={handleNavigateToNode}
/>
)}
</div>
</>
)}
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { useEffect, useState } from "react";
import type { GraphNode } from "../lib/types";
/* Upstream tracker for indexing gaps. The URL is served by the backend
* (/api/ui-config) — the UI security audit forbids hardcoded external URLs in
* graph-ui source, so external targets come from an auditable backend
* response (same pattern as the /api/repo-info deep-links). */
let issuesUrlRequest: Promise<string | null> | null = null;
function fetchIssuesUrl(): Promise<string | null> {
issuesUrlRequest ??= fetch("/api/ui-config")
.then((r) => (r.ok ? r.json() : null))
.then((cfg) => {
const url: unknown = cfg?.upstream_issues_url;
/* Accept only an https URL (regex literal on purpose — the UI security
* audit greps source for protocol strings). */
return typeof url === "string" && /^https:\/\//.test(url) ? url : null;
})
.catch(() => null);
return issuesUrlRequest;
}
interface MissedCalloutProps {
node: GraphNode;
project: string | null;
onClose: () => void;
}
function buildIssueUrl(base: string, path: string, project: string | null): string {
const title = `Indexing gap: ${path}`;
const body = [
"## Not fully indexed (best-effort coverage signal)",
"",
`- **File:** \`${path}\``,
`- **Project:** \`${project ?? "unknown"}\``,
"",
"<!-- Please add: the flagged line ranges from index_status (parse_partial),",
"the language and the construct that fails to parse, and a minimal snippet",
"of the affected code — ONLY if the code is shareable. -->",
"",
"_Reported from the graph UI's missed-coverage view._",
].join("\n");
return `${base}?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
}
function buildAgentPrompt(issuesUrl: string | null, path: string, project: string | null): string {
const where = issuesUrl
? `file a GitHub issue at ${issuesUrl}`
: "file a GitHub issue on the codebase-memory-mcp project";
return (
`codebase-memory-mcp could not fully index \`${path}\`` +
(project ? ` (project \`${project}\`)` : "") +
" — best-effort coverage signal. Please: " +
"1) call the index_status MCP tool and note this file's flagged line ranges under parse_partial; " +
"2) read those ranges in the file and summarize which construct fails to parse; " +
`3) ${where}, titled "Indexing gap: ${path}", ` +
"with the summary — include a minimal reproducible snippet ONLY if the code is shareable."
);
}
/* Right-panel callout shown when a missed-skeleton node is selected: explains
* the gap and offers two working actions — a prefilled upstream issue and a
* ready-made agent prompt (clipboard, with visible feedback). */
export function MissedCallout({ node, project, onClose }: MissedCalloutProps) {
const path = node.file_path || node.name;
const [copied, setCopied] = useState(false);
const [issuesUrl, setIssuesUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchIssuesUrl().then((url) => {
if (!cancelled) setIssuesUrl(url);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(t);
}, [copied]);
const copyPrompt = async () => {
try {
await navigator.clipboard.writeText(buildAgentPrompt(issuesUrl, path, project));
setCopied(true);
} catch {
/* clipboard unavailable (permissions/insecure context) — leave the
* button state unchanged so the failure is visible, not silent */
}
};
return (
<div className="h-full flex flex-col p-4 gap-3 overflow-y-auto">
<div className="flex items-start justify-between">
<div>
<p className="text-[10px] text-foreground/30 uppercase tracking-widest">
Not fully indexed
</p>
<p className="text-sm font-medium text-foreground/90 break-all mt-1">{path}</p>
<p className="text-[10px] text-foreground/40 mt-0.5">{node.label}</p>
</div>
<button
onClick={onClose}
className="text-foreground/40 hover:text-foreground/80 text-sm px-1"
aria-label="Close"
>
×
</button>
</div>
<p className="text-[12px] leading-relaxed text-foreground/70">
We did not manage to fully index this part of your code constructs here may be
missing from the graph (best-effort detection; the file content itself is ground
truth).
</p>
<p className="text-[12px] leading-relaxed text-foreground/70">
Help us handle this edge case too: let your agent summarize what fails to parse
here and file a GitHub issue for the codebase-memory-mcp project.
</p>
<div className="flex flex-col gap-2 mt-1">
<button
onClick={copyPrompt}
className={`text-[12px] font-medium rounded-md border px-3 py-1.5 transition-all text-left ${
copied
? "border-primary/60 bg-primary/15 text-primary"
: "border-white/10 bg-white/[0.03] text-foreground/80 hover:bg-white/[0.07]"
}`}
>
{copied ? "✓ Copied — paste it to your agent" : "Copy agent prompt"}
</button>
{issuesUrl && (
<a
href={buildIssueUrl(issuesUrl, path, project)}
target="_blank"
rel="noreferrer"
className="text-[12px] font-medium rounded-md border border-white/10 bg-white/[0.03] text-foreground/80 hover:bg-white/[0.07] px-3 py-1.5 transition-all"
>
File a GitHub issue (prefilled)
</a>
)}
</div>
<p className="text-[10px] leading-snug text-foreground/35 mt-1">
The prefilled issue contains only the file path and project name add code
snippets only if they are shareable.
</p>
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
import { useEffect, useMemo, useRef } from "react";
import { useThree } from "@react-three/fiber";
import * as THREE from "three";
import type { GraphNode } from "../lib/types";
import { nodeGlowBoost } from "../lib/density";
interface NodeCloudProps {
nodes: GraphNode[];
highlightedIds: Set<number> | null;
onHover: (node: GraphNode | null) => void;
onClick: (node: GraphNode) => void;
opacity?: number;
/* Multiplier on the per-node glow boost. 1 = full boost (sparse graphs),
* 0 = flat colors (dense graphs). Adaptive default × user setting. */
boost?: number;
}
/* Above this count instanced spheres stop paying off (vertex + matrix cost)
* and the cloud switches to point sprites — one position per node. */
const POINT_MODE_THRESHOLD = 75000;
/* Sphere tessellation by node count: nobody can tell a 12-segment sphere from
* a 32-segment one at 25k nodes, but the GPU can. */
function sphereDetail(count: number): [number, number, number] {
if (count <= 8000) return [1, 32, 24];
if (count <= 25000) return [1, 16, 12];
return [1, 10, 7];
}
function nodeColor(
node: GraphNode,
highlightedIds: Set<number> | null,
opacity: number,
boost: number,
tempColor: THREE.Color,
): [number, number, number] {
const hasHighlight = highlightedIds && highlightedIds.size > 0;
tempColor.set(node.color);
if (hasHighlight && !highlightedIds.has(node.id)) {
tempColor.multiplyScalar(0.15);
} else {
/* Boost above 1.0 so bloom picks up the excess as glow corona. Blue hubs
* glow most, red leaves modestly, white/yellow least (see nodeGlowBoost).
* The boost amount also fades toward 1.0 (flat color) as density rises so
* dense graphs stay legible instead of blooming into a white blob. */
const fullBoost = nodeGlowBoost(tempColor.r, tempColor.g, tempColor.b);
const applied = 1 + (fullBoost - 1) * boost;
tempColor.multiplyScalar(applied);
}
return [tempColor.r * opacity, tempColor.g * opacity, tempColor.b * opacity];
}
/* Round, soft-edged sprite for point mode (module-level lazy singleton). */
let pointSprite: THREE.CanvasTexture | null = null;
function getPointSprite(): THREE.CanvasTexture {
if (pointSprite) return pointSprite;
const size = 64;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d")!;
const gradient = ctx.createRadialGradient(
size / 2, size / 2, 0,
size / 2, size / 2, size / 2,
);
gradient.addColorStop(0, "rgba(255,255,255,1)");
gradient.addColorStop(0.5, "rgba(255,255,255,0.9)");
gradient.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
pointSprite = new THREE.CanvasTexture(canvas);
return pointSprite;
}
/* ── Point-sprite mode for very large clouds ──────────────────── */
function NodePoints({
nodes,
highlightedIds,
onHover,
onClick,
opacity,
boost,
}: Required<NodeCloudProps>) {
const { raycaster } = useThree();
/* Widen the raycast threshold while points are on screen */
useEffect(() => {
const prev = raycaster.params.Points?.threshold ?? 1;
raycaster.params.Points = { threshold: 3 };
return () => {
raycaster.params.Points = { threshold: prev };
};
}, [raycaster]);
const { positions, colors } = useMemo(() => {
const positions = new Float32Array(nodes.length * 3);
const colors = new Float32Array(nodes.length * 3);
const tempColor = new THREE.Color();
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
positions[i * 3] = n.x;
positions[i * 3 + 1] = n.y;
positions[i * 3 + 2] = n.z;
const [r, g, b] = nodeColor(n, highlightedIds, opacity, boost, tempColor);
colors[i * 3] = r;
colors[i * 3 + 1] = g;
colors[i * 3 + 2] = b;
}
return { positions, colors };
}, [nodes, highlightedIds, opacity, boost]);
return (
<points
/* Remount when the buffer size changes so stale attributes never linger */
key={nodes.length}
onPointerOver={(e) => {
e.stopPropagation();
if (e.index !== undefined && e.index < nodes.length) {
onHover(nodes[e.index]);
}
}}
onPointerOut={() => onHover(null)}
onClick={(e) => {
e.stopPropagation();
if (e.index !== undefined && e.index < nodes.length) {
onClick(nodes[e.index]);
}
}}
>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
</bufferGeometry>
<pointsMaterial
vertexColors
size={4}
sizeAttenuation
map={getPointSprite()}
alphaTest={0.35}
transparent
toneMapped={false}
/>
</points>
);
}
/* ── Instanced-sphere mode (default) ──────────────────────────── */
function NodeSpheres({
nodes,
highlightedIds,
onHover,
onClick,
opacity,
boost,
}: Required<NodeCloudProps>) {
const meshRef = useRef<THREE.InstancedMesh>(null);
const tempObj = useMemo(() => new THREE.Object3D(), []);
const tempColor = useMemo(() => new THREE.Color(), []);
const detail = sphereDetail(nodes.length);
/* Build instance color attributes — dim non-highlighted nodes */
const colors = useMemo(() => {
const arr = new Float32Array(nodes.length * 3);
for (let i = 0; i < nodes.length; i++) {
const [r, g, b] = nodeColor(
nodes[i],
highlightedIds,
opacity,
boost,
tempColor,
);
arr[i * 3] = r;
arr[i * 3 + 1] = g;
arr[i * 3 + 2] = b;
}
return arr;
}, [nodes, highlightedIds, tempColor, opacity, boost]);
/* Node positions are static (the layout is server-computed), so instance
* matrices only change with the node set or the highlight — never rebuild
* them per frame. */
useEffect(() => {
const mesh = meshRef.current;
if (!mesh) return;
const hasHighlight = highlightedIds && highlightedIds.size > 0;
for (let i = 0; i < nodes.length; i++) {
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);
tempObj.scale.set(s, s, s);
tempObj.updateMatrix();
mesh.setMatrixAt(i, tempObj.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
mesh.computeBoundingSphere();
}, [nodes, highlightedIds, tempObj]);
return (
<instancedMesh
/* Remount when the instance count changes so buffers are re-sized */
key={nodes.length}
ref={meshRef}
args={[undefined, undefined, nodes.length]}
frustumCulled={false}
onPointerOver={(e) => {
e.stopPropagation();
if (e.instanceId !== undefined && e.instanceId < nodes.length) {
onHover(nodes[e.instanceId]);
}
}}
onPointerOut={() => onHover(null)}
onClick={(e) => {
e.stopPropagation();
if (e.instanceId !== undefined && e.instanceId < nodes.length) {
onClick(nodes[e.instanceId]);
}
}}
>
<sphereGeometry args={detail} />
<meshBasicMaterial vertexColors toneMapped={false} />
<instancedBufferAttribute
attach="geometry-attributes-color"
args={[colors, 3]}
/>
</instancedMesh>
);
}
export function NodeCloud({
nodes,
highlightedIds,
onHover,
onClick,
opacity = 1.0,
boost = 1.0,
}: NodeCloudProps) {
if (nodes.length > POINT_MODE_THRESHOLD) {
return (
<NodePoints
nodes={nodes}
highlightedIds={highlightedIds}
onHover={onHover}
onClick={onClick}
opacity={opacity}
boost={boost}
/>
);
}
return (
<NodeSpheres
nodes={nodes}
highlightedIds={highlightedIds}
onHover={onHover}
onClick={onClick}
opacity={opacity}
boost={boost}
/>
);
}
@@ -0,0 +1,96 @@
/* @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",
};
/* The UI security audit forbids literal external URL strings in source, so we
assemble the https scheme at runtime; the built strings are byte-identical. */
const HTTPS = `https:` + `//`;
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 = "<script>window.__pwned = true;</script>\nconst answer = 42;";
callToolMock.mockResolvedValueOnce({ source: payload });
const { container } = render(
<NodeDetailPanel
node={NODE}
allNodes={[NODE]}
allEdges={[]}
project="demo"
repoInfo={REPO}
onClose={() => {}}
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("<script>window.__pwned = true;</script>");
/* …but was NOT parsed into a real <script> element, and did not execute. */
expect(container.querySelector("script")).toBeNull();
expect((window as unknown as { __pwned?: boolean }).__pwned).toBeUndefined();
});
it("builds an https GitHub deep-link with URL-encoded path segments", () => {
render(
<NodeDetailPanel
node={NODE}
allNodes={[NODE]}
allEdges={[]}
project="demo"
repoInfo={REPO}
onClose={() => {}}
onNavigate={() => {}}
/>,
);
const link = screen.getByRole("link", { name: /Open on GitHub/ });
const href = link.getAttribute("href") ?? "";
expect(href.startsWith(`${HTTPS}github.com/org/repo/blob/main/`)).toBe(true);
/* Path segments are percent-encoded; the slashes between them are kept. */
expect(href).toContain("src/weird%20name/%40mod.ts");
expect(href).toContain("#L10-L20");
/* Hardening attributes for target=_blank. */
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
expect(link.getAttribute("target")).toBe("_blank");
});
});
+245
View File
@@ -0,0 +1,245 @@
import { useMemo, useState, useEffect } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { colorForLabel } from "../lib/colors";
import { callTool } from "../api/rpc";
import type { GraphNode, GraphEdge, RepoInfo } from "../lib/types";
interface Connection {
node: GraphNode;
edgeType: string;
direction: "inbound" | "outbound";
}
interface NodeDetailPanelProps {
node: GraphNode;
allNodes: GraphNode[];
allEdges: GraphEdge[];
project: string | null;
repoInfo: RepoInfo | null;
onClose: () => void;
onNavigate: (node: GraphNode) => void;
}
interface SnippetResult {
source?: string;
start_line?: number;
end_line?: number;
}
function lineSuffix(node: GraphNode): string {
if (!node.start_line) return "";
const end = node.end_line && node.end_line !== node.start_line ? `-L${node.end_line}` : "";
return `#L${node.start_line}${end}`;
}
/* Encode each path segment so an unusual file_path can't break (or escape) the
* URL. The scheme is already https-forced by the backend (/api/repo-info);
* this is defense-in-depth on the path. */
function encodePath(p: string): string {
return p.split("/").map(encodeURIComponent).join("/");
}
/* GitHub (or GitLab) deep-link, or null when we lack remote/path/line info. */
function githubUrl(node: GraphNode, repoInfo: RepoInfo | null): string | null {
if (!repoInfo?.blob_base || !node.file_path) return null;
return `${repoInfo.blob_base}/${encodePath(node.file_path)}${lineSuffix(node)}`;
}
export function NodeDetailPanel({
node,
allNodes,
allEdges,
project,
repoInfo,
onClose,
onNavigate,
}: NodeDetailPanelProps) {
const [code, setCode] = useState<string | null>(null);
const [codeLoading, setCodeLoading] = useState(false);
const [codeError, setCodeError] = useState<string | null>(null);
/* Reset the fetched code whenever the selected node changes. */
useEffect(() => {
setCode(null);
setCodeError(null);
setCodeLoading(false);
}, [node.id]);
const canFetchCode = Boolean(project && node.qualified_name);
const ghUrl = githubUrl(node, repoInfo);
const loadCode = async () => {
if (!project || !node.qualified_name) return;
setCodeLoading(true);
setCodeError(null);
try {
const res = await callTool<SnippetResult>("get_code_snippet", {
qualified_name: node.qualified_name,
project,
});
setCode(res.source ?? "(source not available)");
} catch (e) {
setCodeError(e instanceof Error ? e.message : "Failed to load code");
} finally {
setCodeLoading(false);
}
};
const connections = useMemo(() => {
const nodeMap = new Map<number, GraphNode>();
for (const n of allNodes) nodeMap.set(n.id, n);
const conns: Connection[] = [];
for (const edge of allEdges) {
if (edge.source === node.id) {
const t = nodeMap.get(edge.target);
if (t) conns.push({ node: t, edgeType: edge.type, direction: "outbound" });
}
if (edge.target === node.id) {
const s = nodeMap.get(edge.source);
if (s) conns.push({ node: s, edgeType: edge.type, direction: "inbound" });
}
}
return conns;
}, [node, allNodes, allEdges]);
const outbound = connections.filter((c) => c.direction === "outbound");
const inbound = connections.filter((c) => c.direction === "inbound");
const groupByType = (conns: Connection[]) => {
const g = new Map<string, Connection[]>();
for (const c of conns) g.set(c.edgeType, [...(g.get(c.edgeType) ?? []), c]);
return [...g.entries()].sort((a, b) => b[1].length - a[1].length);
};
return (
<div className="w-full bg-[#0b1920]/95 backdrop-blur-xl flex flex-col h-full min-h-0 overflow-hidden">
{/* Header */}
<div className="px-4 pt-4 pb-3 border-b border-border/30">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: colorForLabel(node.label) }} />
<h3 className="text-[13px] font-semibold text-foreground truncate">{node.name}</h3>
</div>
<span
className="inline-block px-2 py-0.5 rounded-md text-[10px] font-medium"
style={{ backgroundColor: colorForLabel(node.label) + "18", color: colorForLabel(node.label) }}
>
{node.label}
</span>
</div>
<button onClick={onClose} className="text-foreground/20 hover:text-foreground/50 transition-colors text-[16px] leading-none p-1">×</button>
</div>
{node.file_path && (
<p className="text-[11px] text-foreground/30 font-mono mt-2 break-all leading-relaxed">
{node.file_path}
{node.start_line ? (
<span className="text-foreground/45">
{" "}:{node.start_line}
{node.end_line && node.end_line !== node.start_line ? `-${node.end_line}` : ""}
</span>
) : null}
</p>
)}
{/* Code actions */}
<div className="flex flex-wrap items-center gap-2 mt-2.5">
{canFetchCode && (
<button
onClick={code ? () => setCode(null) : loadCode}
disabled={codeLoading}
className="px-2.5 py-1 rounded-md bg-primary/15 text-primary text-[11px] font-medium hover:bg-primary/25 transition-colors disabled:opacity-50"
>
{codeLoading ? "Loading…" : code ? "Hide code" : "Show code"}
</button>
)}
{ghUrl && (
<a
href={ghUrl}
target="_blank"
rel="noopener noreferrer"
className="px-2.5 py-1 rounded-md bg-white/[0.05] text-foreground/60 text-[11px] font-medium hover:bg-white/[0.09] hover:text-foreground/90 transition-colors"
>
Open on GitHub
</a>
)}
</div>
{codeError && <p className="text-[11px] text-red-400/80 mt-2">{codeError}</p>}
{code && (
<pre className="mt-2 max-h-[300px] overflow-auto rounded-md bg-black/40 border border-white/[0.06] p-2.5 text-[10.5px] leading-relaxed font-mono text-foreground/75 whitespace-pre">
{code}
</pre>
)}
{/* Stats */}
<div className="flex gap-5 mt-3">
{[
{ label: "Out", value: outbound.length, color: "text-primary" },
{ label: "In", value: inbound.length, color: "text-accent" },
{ label: "Total", value: connections.length, color: "text-foreground" },
].map((s) => (
<div key={s.label}>
<p className="text-[9px] text-foreground/25 uppercase tracking-widest">{s.label}</p>
<p className={`text-[18px] font-semibold tabular-nums ${s.color}`}>{s.value}</p>
</div>
))}
</div>
</div>
{/* Connections */}
<ScrollArea className="flex-1 min-h-0">
<div className="px-4 py-3 space-y-4">
{outbound.length > 0 && (
<ConnectionSection title="References" count={outbound.length} icon="→" groups={groupByType(outbound)} onNavigate={onNavigate} />
)}
{inbound.length > 0 && (
<ConnectionSection title="Referenced by" count={inbound.length} icon="←" groups={groupByType(inbound)} onNavigate={onNavigate} />
)}
{connections.length === 0 && (
<p className="text-[12px] text-foreground/20 text-center py-8">No connections</p>
)}
</div>
</ScrollArea>
</div>
);
}
function ConnectionSection({ title, count, icon, groups, onNavigate }: {
title: string; count: number; icon: string;
groups: [string, Connection[]][];
onNavigate: (n: GraphNode) => void;
}) {
return (
<div>
<p className="text-[11px] font-medium text-foreground/40 mb-2">
{title} <span className="text-foreground/15">({count})</span>
</p>
{groups.map(([type, conns]) => (
<div key={type} className="mb-2">
<p className="text-[9px] text-foreground/20 uppercase tracking-wider mb-1 font-medium">
{type.replace(/_/g, " ").toLowerCase()}
</p>
<div className="space-y-px">
{conns.slice(0, 25).map((c, i) => (
<button
key={`${c.node.id}-${i}`}
onClick={() => onNavigate(c.node)}
className="flex items-center gap-1.5 w-full text-left px-2 py-[4px] rounded-md hover:bg-white/[0.04] text-[11px] transition-colors group"
>
<span className="text-foreground/15 text-[10px] group-hover:text-foreground/30">{icon}</span>
<span className="w-[5px] h-[5px] rounded-full shrink-0" style={{ backgroundColor: colorForLabel(c.node.label) }} />
<span className="text-foreground/55 group-hover:text-foreground/80 truncate">{c.node.name}</span>
<span className="text-foreground/10 ml-auto text-[10px] shrink-0">{c.node.label}</span>
</button>
))}
{conns.length > 25 && (
<p className="text-[10px] text-foreground/15 px-2 py-1">+{conns.length - 25} more</p>
)}
</div>
</div>
))}
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useMemo } from "react";
import * as THREE from "three";
import type { GraphNode } from "../lib/types";
interface NodeLabelsProps {
nodes: GraphNode[];
highlightedIds: Set<number> | null;
maxLabels?: number;
}
interface LabelTexture {
texture: THREE.CanvasTexture;
width: number;
height: number;
}
const TEXTURE_FONT_SIZE = 64;
const TEXTURE_FONT =
`600 ${TEXTURE_FONT_SIZE}px Inter, system-ui, -apple-system, ` +
'BlinkMacSystemFont, "Segoe UI", sans-serif';
const TEXTURE_MAX_TEXT_WIDTH = 720;
const TEXTURE_PADDING_X = 24;
const TEXTURE_PADDING_Y = 14;
const TEXTURE_STROKE_WIDTH = 8;
function fitText(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
): string {
if (ctx.measureText(text).width <= maxWidth) return text;
let lo = 0;
let hi = text.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
const candidate = `${text.slice(0, mid)}...`;
if (ctx.measureText(candidate).width <= maxWidth) lo = mid;
else hi = mid - 1;
}
return `${text.slice(0, Math.max(1, lo))}...`;
}
function createLabelTexture(name: string, color: string): LabelTexture | null {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
return null;
}
ctx.font = TEXTURE_FONT;
const text = fitText(ctx, name, TEXTURE_MAX_TEXT_WIDTH);
const textWidth = Math.ceil(ctx.measureText(text).width);
const logicalWidth = Math.max(
1,
textWidth + TEXTURE_PADDING_X * 2 + TEXTURE_STROKE_WIDTH * 2,
);
const logicalHeight =
TEXTURE_FONT_SIZE + TEXTURE_PADDING_Y * 2 + TEXTURE_STROKE_WIDTH * 2;
const pixelRatio =
typeof window === "undefined"
? 1
: Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.ceil(logicalWidth * pixelRatio);
canvas.height = Math.ceil(logicalHeight * pixelRatio);
canvas.style.width = `${logicalWidth}px`;
canvas.style.height = `${logicalHeight}px`;
ctx.scale(pixelRatio, pixelRatio);
ctx.font = TEXTURE_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.lineJoin = "round";
ctx.lineWidth = TEXTURE_STROKE_WIDTH;
ctx.strokeStyle = "rgba(0, 0, 0, 0.9)";
ctx.fillStyle = color;
const x = logicalWidth / 2;
const y = logicalHeight / 2;
ctx.strokeText(text, x, y);
ctx.fillText(text, x, y);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
texture.needsUpdate = true;
return {
texture,
width: logicalWidth,
height: logicalHeight,
};
}
function NodeLabelSprite({ node }: { node: GraphNode }) {
const label = useMemo(
() => createLabelTexture(node.name, node.color),
[node.name, node.color],
);
useEffect(() => {
return () => label?.texture.dispose();
}, [label]);
if (!label) return null;
const worldFontSize = Math.max(1.8, node.size * 0.4);
const worldHeight = worldFontSize * (label.height / TEXTURE_FONT_SIZE);
const worldWidth = worldHeight * (label.width / label.height);
return (
<sprite
position={[node.x, node.y + node.size * 0.7 + worldHeight / 2, node.z]}
scale={[worldWidth, worldHeight, 1]}
renderOrder={20}
frustumCulled={false}
>
<spriteMaterial
map={label.texture}
transparent
depthWrite={false}
toneMapped={false}
/>
</sprite>
);
}
export function NodeLabels({
nodes,
highlightedIds,
maxLabels = 80,
}: NodeLabelsProps) {
const labeled = useMemo(() => {
const hasHighlight = highlightedIds && highlightedIds.size > 0;
if (hasHighlight) {
return nodes
.filter((n) => highlightedIds.has(n.id))
.sort((a, b) => b.size - a.size)
.slice(0, maxLabels);
}
return [...nodes].sort((a, b) => b.size - a.size).slice(0, maxLabels);
}, [nodes, highlightedIds, maxLabels]);
return (
<group>
{labeled.map((node) => (
<NodeLabelSprite key={node.id} node={node} />
))}
</group>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { Html } from "@react-three/drei";
import type { GraphNode } from "../lib/types";
import { colorForLabel, colorForStatus } from "../lib/colors";
interface NodeTooltipProps {
node: GraphNode;
}
function lineRange(node: GraphNode): string | null {
if (!node.start_line) return null;
if (node.end_line && node.end_line !== node.start_line)
return `L${node.start_line}-${node.end_line}`;
return `L${node.start_line}`;
}
export function NodeTooltip({ node }: NodeTooltipProps) {
return (
<Html
position={[node.x, node.y + node.size * 0.7, node.z]}
center
style={{ pointerEvents: "none" }}
>
<div className="bg-[#1a1a2e]/95 backdrop-blur border border-white/10 rounded-lg px-3 py-2 text-xs whitespace-nowrap shadow-xl max-w-[350px]">
<div className="flex items-center gap-1.5 mb-1">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: colorForLabel(node.label) }}
/>
<span className="text-white font-medium truncate">{node.name}</span>
<span className="text-white/30 ml-1 shrink-0">{node.label}</span>
</div>
{node.file_path && (
<p className="text-white/30 font-mono truncate">
{node.file_path}
{lineRange(node) && <span className="text-white/40"> · {lineRange(node)}</span>}
</p>
)}
{node.status && node.status !== "structural" && (
<div className="flex items-center gap-1.5 mt-1">
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: colorForStatus(node.status) }}
/>
<span className="text-white/45">{node.status}</span>
{node.in_calls !== undefined && (
<span className="text-white/25">
· {node.in_calls} caller{node.in_calls === 1 ? "" : "s"}
</span>
)}
</div>
)}
<p className="text-white/20 mt-1 text-[10px]">click for code </p>
</div>
</Html>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { Project, SchemaInfo } from "../lib/types";
import { colorForLabel } from "../lib/colors";
interface ProjectCardProps {
project: Project;
schema: SchemaInfo | null;
onSelect: (project: string) => void;
}
function formatNumber(n: number): string {
return n.toLocaleString();
}
export function ProjectCard({ project, schema, onSelect }: ProjectCardProps) {
const totalNodes = schema?.node_labels?.reduce((s, l) => s + l.count, 0) ?? 0;
const totalEdges = schema?.edge_types?.reduce((s, t) => s + t.count, 0) ?? 0;
return (
<div className="border border-white/10 rounded-lg p-4 hover:border-white/20 transition-colors">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-white font-medium">{project.name}</h3>
<p className="text-white/40 text-xs font-mono mt-0.5 truncate max-w-[300px]">
{project.root_path}
</p>
</div>
<button
onClick={() => onSelect(project.name)}
className="px-3 py-1 bg-cyan-500/20 hover:bg-cyan-500/30 text-cyan-300 rounded text-xs font-medium transition-colors"
>
View Graph
</button>
</div>
{schema && (
<>
<div className="flex gap-4 text-xs text-white/60 mb-3">
<span>{formatNumber(totalNodes)} nodes</span>
<span>{formatNumber(totalEdges)} edges</span>
</div>
{schema.node_labels && schema.node_labels.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{schema.node_labels.map((l) => (
<span
key={l.label}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs"
style={{ backgroundColor: colorForLabel(l.label) + "20" }}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: colorForLabel(l.label) }}
/>
<span style={{ color: colorForLabel(l.label) }}>
{l.label}
</span>
<span className="text-white/40">{formatNumber(l.count)}</span>
</span>
))}
</div>
)}
</>
)}
{!schema && (
<p className="text-white/30 text-xs italic">Loading schema...</p>
)}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useCallback, useRef } from "react";
interface ResizeHandleProps {
side: "left" | "right"; /* which side the panel is on */
onResize: (delta: number) => void;
}
export function ResizeHandle({ side, onResize }: ResizeHandleProps) {
const dragging = useRef(false);
const lastX = useRef(0);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
dragging.current = true;
lastX.current = e.clientX;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[],
);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
if (!dragging.current) return;
const delta = e.clientX - lastX.current;
lastX.current = e.clientX;
/* Left panel: drag right = bigger (positive delta).
* Right panel: drag left = bigger (negative delta → invert). */
onResize(side === "left" ? delta : -delta);
},
[onResize, side],
);
const onPointerUp = useCallback(() => {
dragging.current = false;
}, []);
return (
<div
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
className="w-1 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors shrink-0"
/>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { useMemo, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { GraphNode } from "../lib/types";
import { useUiMessages } from "../lib/i18n";
interface SidebarProps {
nodes: GraphNode[];
onSelectPath: (path: string, nodeIds: Set<number>) => void;
selectedPath: string | null;
}
interface DirNode {
name: string;
fullPath: string;
children: Map<string, DirNode>;
nodeIds: Set<number>;
directNodes: GraphNode[];
}
function buildFileTree(nodes: GraphNode[]): DirNode {
const root: DirNode = { name: "/", fullPath: "", children: new Map(), nodeIds: new Set(), directNodes: [] };
for (const node of nodes) {
if (!node.file_path) continue;
const parts = node.file_path.split("/");
let cur = root;
for (let i = 0; i < parts.length - 1; i++) {
if (!parts[i]) continue;
let child = cur.children.get(parts[i]);
if (!child) {
const prefix = parts.slice(0, i + 1).join("/");
child = { name: parts[i], fullPath: prefix, children: new Map(), nodeIds: new Set(), directNodes: [] };
cur.children.set(parts[i], child);
}
cur = child;
}
cur.directNodes.push(node);
}
function collect(d: DirNode): Set<number> {
const ids = new Set<number>();
for (const n of d.directNodes) ids.add(n.id);
for (const c of d.children.values()) for (const id of collect(c)) ids.add(id);
d.nodeIds = ids;
return ids;
}
collect(root);
return root;
}
function flattenSingleChild(dir: DirNode): DirNode {
const children = new Map<string, DirNode>();
for (const [key, child] of dir.children) {
let flat = flattenSingleChild(child);
while (flat.children.size === 1 && flat.directNodes.length === 0) {
const [sk, sc] = [...flat.children.entries()][0];
flat = { ...sc, name: `${flat.name}/${sk}`, children: flattenSingleChild(sc).children };
}
children.set(key, flat);
}
return { ...dir, children };
}
function TreeItem({ dir, depth, onSelect, selectedPath }: {
dir: DirNode; depth: number;
onSelect: (path: string, ids: Set<number>) => void;
selectedPath: string | null;
}) {
const [expanded, setExpanded] = useState(false);
const isSelected = selectedPath === dir.fullPath;
const sorted = useMemo(() => [...dir.children.values()].sort((a, b) => a.name.localeCompare(b.name)), [dir.children]);
const sortedNodes = useMemo(() => [...dir.directNodes].sort((a, b) => a.name.localeCompare(b.name)), [dir.directNodes]);
return (
<div>
<button
onClick={() => { setExpanded(!expanded); onSelect(dir.fullPath, dir.nodeIds); }}
className={`flex items-center gap-1.5 w-full text-left px-3 py-[5px] text-[12px] transition-colors ${
isSelected ? "bg-primary/10 text-primary" : "text-foreground/60 hover:text-foreground/80 hover:bg-white/[0.03]"
}`}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
>
<span className="text-foreground/20 w-3 text-center text-[10px] shrink-0">
{(dir.children.size > 0 || dir.directNodes.length > 0) ? (expanded ? "▾" : "▸") : ""}
</span>
<span className="truncate font-medium">{dir.name}</span>
<span className="text-foreground/15 ml-auto text-[10px] tabular-nums shrink-0">{dir.nodeIds.size}</span>
</button>
{expanded && (
<>
{sorted.map((c) => <TreeItem key={c.fullPath} dir={c} depth={depth+1} onSelect={onSelect} selectedPath={selectedPath} />)}
{sortedNodes.map((gn) => (
<button
key={gn.id}
onClick={() => onSelect(dir.fullPath + "/" + gn.name, new Set([gn.id]))}
className="flex items-center gap-1.5 w-full text-left px-3 py-[3px] text-[11px] text-foreground/40 hover:text-foreground/60 hover:bg-white/[0.02] transition-colors"
style={{ paddingLeft: `${(depth+1) * 16 + 12}px` }}
>
<span className="w-[5px] h-[5px] rounded-full shrink-0" style={{ backgroundColor: gn.color }} />
<span className="truncate font-mono">{gn.name}</span>
<span className="text-foreground/10 ml-auto text-[10px] shrink-0">{gn.label}</span>
</button>
))}
</>
)}
</div>
);
}
export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
const t = useUiMessages();
const [search, setSearch] = useState("");
const tree = useMemo(() => flattenSingleChild(buildFileTree(nodes)), [nodes]);
const filtered = useMemo(() => {
if (!search) return null;
const q = search.toLowerCase();
return nodes.filter((n) => n.name.toLowerCase().includes(q) || (n.file_path ?? "").toLowerCase().includes(q)).slice(0, 50);
}, [nodes, search]);
const topLevel = useMemo(() => [...tree.children.values()].sort((a, b) => a.name.localeCompare(b.name)), [tree.children]);
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="px-4 pt-3 pb-2 shrink-0">
<span className="text-[11px] font-medium text-foreground/50 uppercase tracking-widest">
{t.graph.folders}
</span>
</div>
<div className="px-3 pb-2.5 border-b border-border/30 shrink-0">
<div className="relative">
<input
type="text"
placeholder={t.graph.search}
value={search}
onChange={(e) => 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"
/>
</div>
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="py-1">
{filtered ? (
filtered.length === 0 ? (
<p className="text-foreground/20 text-[12px] px-4 py-6 text-center">
{t.common.noMatches}
</p>
) : (
filtered.map((n) => (
<button
key={n.id}
onClick={() => onSelectPath(n.file_path ?? "", new Set([n.id]))}
className="flex items-center gap-2 w-full text-left px-4 py-1.5 text-[11px] hover:bg-white/[0.03] transition-colors"
>
<span className="w-[5px] h-[5px] rounded-full shrink-0" style={{ backgroundColor: n.color }} />
<span className="text-foreground/60 truncate">{n.name}</span>
<span className="text-foreground/15 ml-auto text-[10px] font-mono truncate max-w-[100px]">{n.file_path}</span>
</button>
))
)
) : (
topLevel.map((c) => <TreeItem key={c.fullPath} dir={c} depth={0} onSelect={onSelectPath} selectedPath={selectedPath} />)
)}
</div>
</ScrollArea>
{selectedPath && (
<div className="px-3 py-2 border-t border-border/30">
<button
onClick={() => onSelectPath("", new Set())}
className="w-full px-3 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[11px] text-foreground/40 font-medium transition-all"
>
{t.graph.clearSelection}
</button>
</div>
)}
</div>
);
}
+350
View File
@@ -0,0 +1,350 @@
/* @vitest-environment jsdom */
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StatsTab, IndexProgress } from "./StatsTab";
import { messages } from "../lib/i18n";
function mockProjectsFetch(extra?: (url: string, init?: RequestInit) => Response | undefined) {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const overridden = extra?.(url, init);
if (overridden) return overridden;
if (url === "/rpc") {
return new Response(JSON.stringify({
result: { content: [{ text: JSON.stringify({ projects: [] }) }] },
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.startsWith("/api/ui-config")) {
return new Response(JSON.stringify({ lang: "en" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url.startsWith("/api/browse")) {
return new Response(JSON.stringify({
path: "/home/dev",
parent: "/home",
dirs: ["alpha", "beta"],
roots: ["/", "D:/"],
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url === "/api/index") {
return new Response(JSON.stringify({ status: "indexing", slot: 0 }), {
status: 202,
headers: { "Content-Type": "application/json" },
});
}
return new Response("{}", { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("StatsTab index modal", () => {
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("submits a custom path and project name", async () => {
let submitted: unknown = null;
mockProjectsFetch((url, init) => {
if (url === "/api/index") {
submitted = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ status: "indexing", slot: 0 }), {
status: 202,
headers: { "Content-Type": "application/json" },
});
}
return undefined;
});
render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
fireEvent.change(await screen.findByLabelText("Repository path"), {
target: { value: "D:\\work\\信租风控通后端" },
});
fireEvent.change(screen.getByLabelText("Project ID (optional — permanent, cannot be renamed)"), {
target: { value: "信租风控通后端" },
});
fireEvent.click(screen.getByRole("button", { name: "Index This Folder" }));
await waitFor(() => {
expect(submitted).toEqual({
root_path: "D:\\work\\信租风控通后端",
project_name: "信租风控通后端",
});
});
});
it("filters picker rows and exposes quick row indexing", async () => {
mockProjectsFetch();
render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
fireEvent.change(await screen.findByPlaceholderText("Filter folders"), {
target: { value: "bet" },
});
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
expect(screen.getByText("beta")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Index beta" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Browse D:/" })).toBeInTheDocument();
});
it("navigates Windows breadcrumb segments to real drive paths", async () => {
const fetchMock = mockProjectsFetch((url) => {
if (url.startsWith("/api/browse")) {
return new Response(JSON.stringify({
path: "C:/Users/rap",
parent: "C:/Users",
dirs: ["Documents", "Downloads"],
roots: ["C:/", "D:/"],
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return undefined;
});
render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
/* No bogus unified "/" root crumb on a Windows drive path. */
await screen.findByRole("button", { name: "C:" });
expect(screen.queryByRole("button", { name: "/" })).not.toBeInTheDocument();
/* Clicking the drive crumb browses to "C:/", not "/C:". */
fireEvent.click(screen.getByRole("button", { name: "C:" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/browse?path=C%3A%2F");
});
/* Clicking a nested crumb browses to "C:/Users", not "/C:/Users". */
fireEvent.click(screen.getByRole("button", { name: "Users" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/browse?path=C%3A%2FUsers");
});
});
it("refreshes the folder list when a drive is typed into the path field", async () => {
mockProjectsFetch((url) => {
if (url.startsWith("/api/browse")) {
const m = /[?&]path=([^&]*)/.exec(url);
const path = m ? decodeURIComponent(m[1]) : "C:/Users/rap";
const onD = path.replace(/\\/g, "/").toUpperCase().startsWith("D:");
return new Response(JSON.stringify({
path,
parent: "C:/",
dirs: onD ? ["projects", "games"] : ["Documents", "Downloads"],
roots: ["C:/", "D:/"],
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return undefined;
});
render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
/* Initial C: listing is shown. */
expect(await screen.findByText("Documents")).toBeInTheDocument();
/* Typing a different drive refreshes the listing to that drive (debounced). */
fireEvent.change(await screen.findByLabelText("Repository path"), {
target: { value: "D:/" },
});
expect(await screen.findByText("projects")).toBeInTheDocument();
expect(screen.queryByText("Documents")).not.toBeInTheDocument();
});
it("replaces the meaningless '/' root with the drive on Windows", async () => {
const fetchMock = mockProjectsFetch((url) => {
if (url.startsWith("/api/browse")) {
const m = /[?&]path=([^&]*)/.exec(url);
const path = m ? decodeURIComponent(m[1]) : "C:/Users/rap";
return new Response(JSON.stringify({
path,
parent: "C:/",
dirs: ["Documents"],
roots: ["/"], // older backend: no drive enumeration
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return undefined;
});
render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
/* The bogus "/" quick-jump is gone; the current drive root is offered. */
expect(await screen.findByRole("button", { name: "Browse C:/" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Browse /" })).not.toBeInTheDocument();
/* Clicking it browses to the drive root, not "/". */
fireEvent.click(screen.getByRole("button", { name: "Browse C:/" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/browse?path=C%3A%2F");
});
});
it("does not auto-refresh on POSIX when a path is typed", async () => {
const fetchMock = mockProjectsFetch(); // browse returns POSIX path "/home/dev"
render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
await screen.findByText("alpha"); // initial POSIX listing
const browseCalls = () =>
fetchMock.mock.calls.filter((c) => String(c[0]).startsWith("/api/browse")).length;
const before = browseCalls();
fireEvent.change(screen.getByLabelText("Repository path"), {
target: { value: "/usr/local" },
});
/* Wait past the debounce window; a POSIX path must NOT trigger a re-browse. */
await new Promise((r) => setTimeout(r, 400));
expect(browseCalls()).toBe(before);
});
});
describe("IndexProgress", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("polls and shows indexing in progress when active", async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve({
json: () => Promise.resolve([
{ slot: 1, status: "indexing", path: "/path/to/project1" }
])
} as unknown as Response)
);
vi.stubGlobal("fetch", fetchMock);
const onDone = vi.fn();
render(<IndexProgress onDone={onDone} />);
// Fast-forward initial poll
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(fetchMock).toHaveBeenCalledWith("/api/index-status");
expect(screen.getByText(messages.en.projects.indexingInProgress)).toBeInTheDocument();
expect(screen.getByText("/path/to/project1")).toBeInTheDocument();
expect(onDone).not.toHaveBeenCalled();
});
it("stops polling and calls onDone when indexing finishes successfully", async () => {
// Backend keeps finished jobs listed with status "done" (src/ui/http_server.c
// handle_index_status only skips idle slots) — success is a "done" entry,
// not an empty list.
let mockData = [
{ slot: 1, status: "indexing", path: "/path/to/project" }
];
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve({
json: () => Promise.resolve(mockData)
} as unknown as Response)
);
vi.stubGlobal("fetch", fetchMock);
const onDone = vi.fn();
render(<IndexProgress onDone={onDone} />);
// First poll returns active
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(onDone).not.toHaveBeenCalled();
// Indexing finishes
mockData = [
{ slot: 1, status: "done", path: "/path/to/project" }
];
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(onDone).toHaveBeenCalled();
});
it("keeps waiting and does NOT call onDone while the jobs list is empty", async () => {
// An empty list mid-index means the job is not visible (e.g. transient
// backend state loss) — it must NOT be treated as successful completion.
let mockData: { slot: number; status: string; path: string }[] = [];
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve({
json: () => Promise.resolve(mockData)
} as unknown as Response)
);
vi.stubGlobal("fetch", fetchMock);
const onDone = vi.fn();
render(<IndexProgress onDone={onDone} />);
// Two empty polls: still waiting, no premature completion
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(onDone).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(2);
// Job becomes visible and finishes — now completion fires
mockData = [{ slot: 1, status: "done", path: "/path/to/project" }];
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(onDone).toHaveBeenCalled();
});
it("renders error banner and does NOT call onDone when indexing fails with error status", async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve({
json: () => Promise.resolve([
{ slot: 1, status: "error", path: "/path/to/failed-project", error: "OOM Error" }
])
} as unknown as Response)
);
vi.stubGlobal("fetch", fetchMock);
const onDone = vi.fn();
render(<IndexProgress onDone={onDone} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
// Error banner should show up
expect(screen.getByText(messages.en.projects.indexingFailed)).toBeInTheDocument();
expect(screen.getByText("/path/to/failed-project")).toBeInTheDocument();
expect(screen.getByText("OOM Error")).toBeInTheDocument();
// onDone should not be called automatically
expect(onDone).not.toHaveBeenCalled();
// Click Dismiss button
const dismissBtn = screen.getByRole("button", { name: messages.en.common.dismiss });
expect(dismissBtn).toBeInTheDocument();
await act(async () => {
fireEvent.click(dismissBtn);
});
// onDone should be called after manual dismissal
expect(onDone).toHaveBeenCalled();
});
});
+601
View File
@@ -0,0 +1,601 @@
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useProjects } from "../hooks/useProjects";
import { colorForLabel } from "../lib/colors";
import { useUiMessages } from "../lib/i18n";
interface StatsTabProps {
onSelectProject: (project: string) => void;
}
/* ── Glowy health dot ───────────────────────────────────── */
function HealthDot({ name }: { name: string }) {
const t = useUiMessages();
const [status, setStatus] = useState<"loading" | "healthy" | "corrupt" | "missing">("loading");
const [info, setInfo] = useState("");
useEffect(() => {
fetch(`/api/project-health?name=${encodeURIComponent(name)}`)
.then((r) => r.json())
.then((d) => {
setStatus(d.status ?? "corrupt");
if (d.nodes !== undefined) {
const sizeMB = ((d.size_bytes ?? 0) / 1024 / 1024).toFixed(1);
setInfo(`${d.nodes.toLocaleString()} nodes, ${d.edges.toLocaleString()} edges, ${sizeMB} MB`);
} else if (d.reason) {
setInfo(d.reason);
}
})
.catch(() => setStatus("corrupt"));
}, [name]);
const dotColor =
status === "healthy" ? "#34d399" :
status === "missing" ? "#fbbf24" :
status === "corrupt" ? "#f87171" : "#555";
const label =
status === "healthy" ? t.projects.healthHealthy :
status === "missing" ? t.projects.healthMissing :
status === "corrupt" ? t.projects.healthCorrupt : t.projects.healthChecking;
return (
<div className="group relative inline-flex items-center">
{/* Glow layer */}
<span
className="absolute w-3 h-3 rounded-full animate-pulse opacity-40 blur-[3px]"
style={{ backgroundColor: dotColor }}
/>
{/* Dot */}
<span
className="relative w-[8px] h-[8px] rounded-full"
style={{ backgroundColor: dotColor, boxShadow: `0 0 6px ${dotColor}80` }}
/>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 hidden group-hover:block z-20 pointer-events-none">
<div className="bg-[#0b1920] border border-border/50 rounded-lg px-3 py-2 text-[11px] whitespace-nowrap shadow-xl">
<p className="font-medium" style={{ color: dotColor }}>{label}</p>
{info && <p className="text-foreground/35 text-[10px] mt-0.5">{info}</p>}
</div>
</div>
</div>
);
}
/* ── ADR button + modal ─────────────────────────────────── */
function AdrButton({ project }: { project: string }) {
const t = useUiMessages();
const [hasAdr, setHasAdr] = useState<boolean | null>(null);
const [open, setOpen] = useState(false);
const [content, setContent] = useState("");
const [saving, setSaving] = useState(false);
const [updatedAt, setUpdatedAt] = useState("");
const fetchAdr = useCallback(async () => {
try {
const res = await fetch(`/api/adr?project=${encodeURIComponent(project)}`);
const data = await res.json();
setHasAdr(data.has_adr ?? false);
if (data.content) setContent(data.content);
if (data.updated_at) setUpdatedAt(data.updated_at);
} catch { setHasAdr(false); }
}, [project]);
useEffect(() => { fetchAdr(); }, [fetchAdr]);
const save = async () => {
setSaving(true);
try {
await fetch("/api/adr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project, content }),
});
await fetchAdr();
setOpen(false);
} catch { /* ignore */ }
finally { setSaving(false); }
};
if (hasAdr === null) return null;
return (
<>
<button
onClick={() => { setOpen(true); fetchAdr(); }}
className={`px-2.5 py-1 rounded-lg text-[10px] font-medium transition-all ${
hasAdr
? "bg-accent/15 text-accent hover:bg-accent/25"
: "bg-white/[0.03] text-foreground/25 hover:text-foreground/40 hover:bg-white/[0.06]"
}`}
>
{hasAdr ? "ADR" : "+ ADR"}
</button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setOpen(false)}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="relative bg-[#0e2028] border border-border/40 rounded-2xl p-6 w-full max-w-2xl shadow-2xl max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-[15px] font-semibold text-foreground/90">{t.adr.title}</h3>
<p className="text-[11px] text-foreground/30 font-mono mt-0.5">{project}</p>
</div>
<button onClick={() => setOpen(false)} className="text-foreground/20 hover:text-foreground/50 text-[16px] p-1">×</button>
</div>
{updatedAt && (
<p className="text-[10px] text-foreground/20 mb-3">{t.adr.lastUpdated}: {updatedAt}</p>
)}
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={"# Architecture Decision Record\n\n## Context\n...\n\n## Decision\n...\n\n## Consequences\n..."}
className="flex-1 min-h-[300px] bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-3 text-[12px] text-foreground font-mono placeholder-foreground/15 outline-none focus:border-primary/30 resize-none leading-relaxed"
/>
<div className="flex justify-end gap-2 mt-4">
{hasAdr && (
<button
onClick={async () => {
setContent(""); await save();
}}
className="px-3 py-2 rounded-lg text-[12px] text-destructive/60 hover:text-destructive hover:bg-destructive/10 font-medium transition-all"
>
{t.common.delete}
</button>
)}
<button onClick={() => setOpen(false)} className="px-4 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">{t.common.cancel}</button>
<button onClick={save} disabled={saving} className="px-4 py-2 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary text-[12px] font-medium transition-all disabled:opacity-30">
{saving ? t.common.saving : t.common.save}
</button>
</div>
</div>
</div>
)}
</>
);
}
/* ── Create Index Modal ─────────────────────────────────── */
function joinPath(base: string, dir: string): string {
if (!base || base === "/") return `/${dir}`;
if (/^[A-Za-z]:[\\/]?$/.test(base)) return `${base[0]}:/${dir}`;
const slash = base.includes("\\") && !base.includes("/") ? "\\" : "/";
return `${base.replace(/[\\/]+$/, "")}${slash}${dir}`;
}
function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const t = useUiMessages();
const [currentPath, setCurrentPath] = useState("");
const [dirs, setDirs] = useState<string[]>([]);
const [roots, setRoots] = useState<string[]>(["/"]);
const [parentPath, setParentPath] = useState("");
const [projectName, setProjectName] = useState("");
const [filter, setFilter] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const filterRef = useRef<HTMLInputElement>(null);
/* Path whose listing is currently shown. Lets the typed-path effect skip a
* redundant re-fetch after browse() sets currentPath itself. */
const lastBrowsedRef = useRef<string>("");
const browse = useCallback(async (path?: string, opts?: { silent?: boolean }) => {
const silent = opts?.silent ?? false;
if (!silent) setLoading(true);
setError(null);
try {
const q = path ? `?path=${encodeURIComponent(path)}` : "";
const res = await fetch(`/api/browse${q}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
lastBrowsedRef.current = data.path ?? "";
setCurrentPath(data.path ?? "");
setDirs((data.dirs ?? []).sort());
setRoots(data.roots ?? ["/"]);
setParentPath(data.parent ?? "/");
} catch (e) {
/* Silent (typed-path) refreshes keep the last good listing instead of
* flashing an error while the user is still typing a path. */
if (!silent) setError(e instanceof Error ? e.message : "Browse failed");
}
finally { if (!silent) setLoading(false); }
}, []);
useEffect(() => { browse(); }, [browse]);
useEffect(() => { filterRef.current?.focus(); }, []);
/* Windows only: when the user types a drive path into the Repository path
* field, refresh the folder listing to match (debounced). On Windows, typing
* is the way to switch drives, and without this the breadcrumb and path box
* updated but the directory list stayed stale (e.g. typing "D:/" still showed
* the previous drive's folders). POSIX navigation is left unchanged. */
useEffect(() => {
if (!currentPath || currentPath === lastBrowsedRef.current) return;
if (!/^[A-Za-z]:/.test(currentPath.replace(/\\/g, "/"))) return;
const id = setTimeout(() => { void browse(currentPath, { silent: true }); }, 350);
return () => clearTimeout(id);
}, [currentPath, browse]);
const filteredDirs = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return dirs;
return dirs.filter((d) => d.toLowerCase().includes(q));
}, [dirs, filter]);
useEffect(() => { setActiveIndex(0); }, [filter, currentPath]);
const submit = async (path = currentPath) => {
if (!path) return;
setSubmitting(true); setError(null);
try {
const body: { root_path: string; project_name?: string } = { root_path: path };
if (projectName.trim()) body.project_name = projectName.trim();
const res = await fetch("/api/index", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Failed");
onCreated(); onClose();
} catch (e) { setError(e instanceof Error ? e.message : "Failed"); }
finally { setSubmitting(false); }
};
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, Math.max(filteredDirs.length - 1, 0)));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && filteredDirs.length > 0) {
e.preventDefault();
const dir = filteredDirs.length === 1 ? filteredDirs[0] : filteredDirs[activeIndex];
if (filteredDirs.length === 1) void submit(joinPath(currentPath, dir));
else void browse(joinPath(currentPath, dir));
}
};
/* Breadcrumb segments */
const displayPath = currentPath.replace(/\\/g, "/");
const segments = displayPath.split("/").filter(Boolean);
/* A Windows drive path ("C:/Users/rap") has no unified "/" root — its first
* segment is the drive letter. Build crumb targets accordingly so clicking a
* segment navigates to a real directory instead of a bogus "/C:/..." path
* that the backend rejects as "not a directory". */
const isWinPath = /^[A-Za-z]:$/.test(segments[0] ?? "");
const crumbPath = (i: number): string => {
const parts = segments.slice(0, i + 1);
if (isWinPath) return parts.length === 1 ? `${parts[0]}/` : parts.join("/");
return "/" + parts.join("/");
};
/* Root/drive quick-jump buttons. On Windows the POSIX "/" root is meaningless
* — browsing it returns an empty listing — so drop it and offer drive roots
* instead. An older backend may not enumerate drives, so always include the
* current drive; other drives stay reachable by typing a path. */
const displayRoots = (() => {
if (!isWinPath) return roots;
const drives = Array.from(new Set(
roots.filter((r) => /^[A-Za-z]:[\\/]?$/.test(r)).map((r) => `${r[0].toUpperCase()}:/`),
));
const curRoot = `${displayPath[0].toUpperCase()}:/`;
if (!drives.includes(curRoot)) drives.unshift(curRoot);
return drives;
})();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="relative bg-[#0e2028] border border-border/40 rounded-2xl w-full max-w-2xl shadow-2xl flex flex-col overflow-hidden" style={{ height: "min(82vh, 680px)" }} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="px-5 pt-5 pb-3 shrink-0">
<h3 className="text-[15px] font-semibold text-foreground/90 mb-1">{t.index.selectRepositoryFolder}</h3>
<p className="text-[12px] text-foreground/30">{t.index.instructions}</p>
</div>
<div className="px-5 pb-3 grid grid-cols-[1fr_220px] gap-3 shrink-0">
<label className="block">
<span className="block text-[10px] uppercase tracking-widest text-foreground/25 mb-1">{t.index.repositoryPath}</span>
<input
aria-label={t.index.repositoryPath}
value={currentPath}
onChange={(e) => setCurrentPath(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && /^[A-Za-z]:/.test(currentPath.replace(/\\/g, "/"))) { e.preventDefault(); void browse(currentPath); } }}
className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground font-mono outline-none focus:border-primary/40"
/>
</label>
<label className="block">
<span className="block text-[10px] uppercase tracking-widest text-foreground/25 mb-1">{t.index.projectName}</span>
<input
aria-label={t.index.projectName}
value={projectName}
placeholder={t.index.projectNamePlaceholder}
onChange={(e) => setProjectName(e.target.value)}
className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground outline-none focus:border-primary/40 placeholder:text-foreground/20"
/>
<span className="block text-[10px] text-foreground/25 mt-1">{t.index.projectNameHelp}</span>
</label>
</div>
<div className="px-5 pb-3 flex items-center gap-2 shrink-0">
<input
ref={filterRef}
value={filter}
placeholder={t.index.filterFolders}
onChange={(e) => setFilter(e.target.value)}
onKeyDown={onFilterKeyDown}
className="flex-1 bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground outline-none focus:border-primary/40 placeholder:text-foreground/20"
/>
<div className="flex items-center gap-1">
{displayRoots.map((root) => (
<button
key={root}
aria-label={t.index.browseRoot(root)}
onClick={() => browse(root)}
className="px-2.5 py-2 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[11px] text-foreground/45 font-mono transition-all"
>
{root}
</button>
))}
</div>
</div>
{/* Breadcrumb */}
<div className="px-5 py-2 border-y border-border/20 flex items-center gap-0.5 overflow-x-auto text-[11px] shrink-0">
{!isWinPath && (
<button onClick={() => browse("/")} className="text-primary/60 hover:text-primary shrink-0 transition-colors">/</button>
)}
{segments.map((seg, i) => (
<span key={i} className="flex items-center gap-0.5 shrink-0">
{(i > 0 || !isWinPath) && <span className="text-foreground/15">/</span>}
<button
onClick={() => browse(crumbPath(i))}
className={`transition-colors ${i === segments.length - 1 ? "text-foreground/70 font-medium" : "text-primary/50 hover:text-primary"}`}
>
{seg}
</button>
</span>
))}
</div>
{/* Directory list */}
<ScrollArea className="flex-1 min-h-0">
<div className="px-2 py-1">
{/* Go up */}
{currentPath !== "/" && (
<button
onClick={() => browse(parentPath)}
className="flex items-center gap-2 w-full text-left px-3 py-2 rounded-lg hover:bg-white/[0.04] text-[12px] text-foreground/40 transition-colors"
>
<span className="text-foreground/20"></span>
<span>..</span>
</button>
)}
{loading ? (
<p className="text-foreground/20 text-[12px] text-center py-8">{t.common.loading}</p>
) : filteredDirs.length === 0 ? (
<p className="text-foreground/15 text-[12px] text-center py-8">{t.index.noSubdirectories}</p>
) : (
filteredDirs.map((d, i) => (
<div
key={d}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-[12px] transition-colors group ${
i === activeIndex ? "bg-white/[0.05]" : "hover:bg-white/[0.04]"
}`}
>
<button
aria-label={t.index.browseRoot(d)}
onClick={() => browse(joinPath(currentPath, d))}
className="flex min-w-0 flex-1 items-center gap-2 text-left text-foreground/60"
>
<span className="text-foreground/20 group-hover:text-foreground/40">/</span>
<span className="truncate">{d}</span>
</button>
<button
aria-label={t.index.indexDirectory(d)}
onClick={() => submit(joinPath(currentPath, d))}
disabled={submitting}
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 px-2 py-1 rounded-md bg-primary/15 hover:bg-primary/25 text-primary text-[10px] font-medium transition-all disabled:opacity-30"
>
{t.index.indexThisFolder}
</button>
</div>
))
)}
</div>
</ScrollArea>
{/* Footer */}
<div className="px-5 py-4 border-t border-border/20 shrink-0">
{error && <div className="rounded-lg bg-destructive/10 border border-destructive/20 px-3 py-2 mb-3"><p className="text-destructive text-[11px]">{error}</p></div>}
<div className="flex items-center justify-between">
<p className="text-[11px] text-foreground/25 font-mono truncate max-w-[250px]">{currentPath}</p>
<div className="flex gap-2 shrink-0">
<button onClick={onClose} className="px-3 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">{t.common.cancel}</button>
<button onClick={() => submit()} disabled={submitting || !currentPath} className="px-4 py-2 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary text-[12px] font-medium transition-all disabled:opacity-30">
{submitting ? t.index.starting : t.index.indexThisFolder}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
/* ── Index Progress ─────────────────────────────────────── */
export function IndexProgress({ onDone }: { onDone: () => void }) {
const t = useUiMessages();
const [jobs, setJobs] = useState<{ slot: number; status: string; path: string; error?: string }[]>([]);
const [hasActive, setHasActive] = useState(true);
useEffect(() => {
if (!hasActive) return;
const poll = setInterval(async () => {
try {
const data = await (await fetch("/api/index-status")).json();
setJobs(data);
const stillIndexing = data.some((j: { status: string }) => j.status === "indexing");
/* Empty list = job not visible: the backend keeps finished jobs listed
as "done"/"error", so [] mid-index only happens on transient state
loss (e.g. server restart) — keep polling, don't treat as done. */
if (data.length > 0 && !stillIndexing) {
setHasActive(false);
const hasErrors = data.some((j: { status: string }) => j.status === "error");
if (!hasErrors) {
onDone();
}
}
} catch (error) {
console.error("[IndexProgress] Poll failed:", error);
}
}, 2000);
return () => clearInterval(poll);
}, [onDone, hasActive]);
const active = jobs.filter((j) => j.status === "indexing");
const errors = jobs.filter((j) => j.status === "error");
if (active.length === 0 && errors.length === 0) return null;
return (
<div className="rounded-xl border border-primary/20 bg-primary/5 p-4 mb-6">
{active.map((j) => (
<div key={j.slot} className="flex items-center gap-3">
<div className="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin shrink-0" />
<div>
<p className="text-[12px] text-primary font-medium">{t.projects.indexingInProgress}</p>
<p className="text-[11px] text-foreground/30 font-mono">{j.path}</p>
</div>
</div>
))}
{errors.map((j) => (
<div key={j.slot} className="flex items-start gap-3 mt-3 first:mt-0 p-3 rounded-lg border border-destructive/20 bg-destructive/5 text-destructive">
<span className="text-[14px]"></span>
<div className="flex-1 min-w-0">
<p className="text-[12px] font-semibold">{t.projects.indexingFailed}</p>
<p className="text-[11px] font-mono truncate">{j.path}</p>
{j.error && <p className="text-[10px] opacity-75 mt-1 font-mono">{j.error}</p>}
</div>
</div>
))}
{errors.length > 0 && (
<div className="flex justify-end mt-3">
<button
onClick={onDone}
className="px-3 py-1 rounded bg-destructive/10 hover:bg-destructive/20 text-destructive text-[11px] font-medium transition-all"
>
{t.common.dismiss}
</button>
</div>
)}
</div>
);
}
/* ── Main Stats Tab ─────────────────────────────────────── */
export function StatsTab({ onSelectProject }: StatsTabProps) {
const t = useUiMessages();
const { projects, loading, error, refresh } = useProjects();
const [showModal, setShowModal] = useState(false);
const [indexing, setIndexing] = useState(false);
const aggregate = useMemo(() => {
let totalNodes = 0, totalEdges = 0;
for (const p of projects) {
totalNodes += p.schema?.node_labels?.reduce((s, l) => s + l.count, 0) ?? 0;
totalEdges += p.schema?.edge_types?.reduce((s, t) => s + t.count, 0) ?? 0;
}
return { projects: projects.length, nodes: totalNodes, edges: totalEdges };
}, [projects]);
const deleteProject = useCallback(async (name: string) => {
if (!confirm(t.projects.deleteConfirm(name))) return;
try { await fetch(`/api/project?name=${encodeURIComponent(name)}`, { method: "DELETE" }); refresh(); } catch { /* */ }
}, [refresh, t.projects]);
return (
<ScrollArea className="h-full">
<div className="p-8 max-w-3xl mx-auto">
{projects.length > 0 && (
<div className="flex gap-4 mb-8">
{[
{ label: t.tabs.projects, value: aggregate.projects, color: "text-primary" },
{ label: t.projects.nodes, value: aggregate.nodes, color: "text-foreground/80" },
{ label: t.projects.edges, value: aggregate.edges, color: "text-foreground/80" },
].map((s) => (
<div key={s.label} className="flex-1 rounded-xl border border-border/30 bg-white/[0.02] p-4">
<p className="text-[10px] text-foreground/25 uppercase tracking-widest mb-1">{s.label}</p>
<p className={`text-[22px] font-semibold tabular-nums ${s.color}`}>{s.value.toLocaleString()}</p>
</div>
))}
</div>
)}
{indexing && <IndexProgress onDone={() => { setIndexing(false); refresh(); }} />}
<div className="flex items-center justify-between mb-6">
<h2 className="text-[15px] font-semibold text-foreground/80">{t.projects.indexedProjects}</h2>
<div className="flex items-center gap-2">
<button onClick={() => setShowModal(true)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">+ {t.index.newIndex}</button>
<button onClick={refresh} disabled={loading} className="px-3 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[12px] text-foreground/40 font-medium transition-all disabled:opacity-30">{loading ? "..." : t.common.refresh}</button>
</div>
</div>
{error && <div className="rounded-xl border border-destructive/20 bg-destructive/5 p-4 mb-6"><p className="text-destructive text-[13px]">{error}</p></div>}
{!loading && projects.length === 0 && !error && (
<div className="text-center py-20">
<p className="text-foreground/25 text-[13px] mb-2">{t.projects.noIndexedProjects}</p>
<button onClick={() => setShowModal(true)} className="px-4 py-2 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">{t.projects.indexFirstRepository}</button>
</div>
)}
<div className="space-y-3">
{projects.map((p) => {
const totalNodes = p.schema?.node_labels?.reduce((s, l) => s + l.count, 0) ?? 0;
const totalEdges = p.schema?.edge_types?.reduce((s, t) => s + t.count, 0) ?? 0;
return (
<div key={p.project.name} className="rounded-xl border border-border/30 bg-white/[0.02] hover:bg-white/[0.035] transition-all p-5">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0 flex items-start gap-2.5">
<div className="mt-1.5"><HealthDot name={p.project.name} /></div>
<div className="min-w-0">
<h3 className="text-[14px] font-semibold text-foreground/90 mb-0.5">{p.project.name}</h3>
<p className="text-[11px] text-foreground/20 font-mono truncate">{p.project.root_path}</p>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<AdrButton project={p.project.name} />
<button onClick={() => onSelectProject(p.project.name)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">{t.projects.viewGraph}</button>
<button onClick={() => deleteProject(p.project.name)} className="px-2 py-1.5 rounded-lg hover:bg-destructive/10 text-foreground/20 hover:text-destructive text-[12px] transition-all" title={t.projects.deleteTitle}></button>
</div>
</div>
{p.schema && (
<>
<div className="flex gap-6 text-[12px] text-foreground/30 mb-3">
<span><strong className="text-foreground/55 tabular-nums">{totalNodes.toLocaleString()}</strong> {t.projects.nodes}</span>
<span><strong className="text-foreground/55 tabular-nums">{totalEdges.toLocaleString()}</strong> {t.projects.edges}</span>
</div>
<div className="flex flex-wrap gap-1">
{p.schema.node_labels?.map((l) => (
<span key={l.label} className="inline-flex items-center gap-1 px-1.5 py-[2px] rounded-md text-[10px] font-medium" style={{ backgroundColor: colorForLabel(l.label) + "10", color: colorForLabel(l.label) + "bb" }}>
<span className="w-[4px] h-[4px] rounded-full" style={{ backgroundColor: colorForLabel(l.label) }} />
{l.label} {l.count.toLocaleString()}
</span>
))}
</div>
</>
)}
</div>
);
})}
</div>
</div>
{showModal && <CreateIndexModal onClose={() => setShowModal(false)} onCreated={() => { setIndexing(true); refresh(); }} />}
</ScrollArea>
);
}
+2
View File
@@ -0,0 +1,2 @@
/* TabBar moved inline into App.tsx header — this file kept for compatibility */
export {};
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,56 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+96
View File
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchLayout,
clampNodeBudget,
GRAPH_RENDER_NODE_LIMIT,
GRAPH_NODE_BUDGET_STEP,
GRAPH_NODE_BUDGET_MAX,
} from "./useGraphData";
describe("fetchLayout", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("uses the default node budget when none is given", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ nodes: [], edges: [], total_nodes: 0 }),
}));
vi.stubGlobal("fetch", fetchMock);
await fetchLayout("large-project");
expect(GRAPH_RENDER_NODE_LIMIT).toBe(5000);
expect(fetchMock).toHaveBeenCalledTimes(1);
const calls = fetchMock.mock.calls as unknown as Array<[string]>;
const [url] = calls[0];
expect(url).toBe(
"/api/layout?project=large-project&max_nodes=5000",
);
});
it("passes an explicit node budget through to the layout endpoint", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ nodes: [], edges: [], total_nodes: 0 }),
}));
vi.stubGlobal("fetch", fetchMock);
await fetchLayout("large-project", 250000);
const calls = fetchMock.mock.calls as unknown as Array<[string]>;
const [url] = calls[0];
expect(url).toBe(
"/api/layout?project=large-project&max_nodes=250000",
);
});
it("reports streaming progress while the body downloads", async () => {
const payload = new TextEncoder().encode(
JSON.stringify({ nodes: [], edges: [], total_nodes: 7 }),
);
const half = Math.floor(payload.length / 2);
const chunks = [payload.slice(0, half), payload.slice(half)];
let read = 0;
const fetchMock = vi.fn(async () => ({
ok: true,
headers: new Headers({ "content-length": String(payload.length) }),
body: {
getReader: () => ({
read: async () =>
read < chunks.length
? { done: false, value: chunks[read++] }
: { done: true, value: undefined },
}),
},
json: async () => {
throw new Error("json() must not be used when streaming");
},
}));
vi.stubGlobal("fetch", fetchMock);
const seen: number[] = [];
const result = await fetchLayout("p", 5000, (p) => {
seen.push(p.receivedBytes);
expect(p.totalBytes).toBe(payload.length);
});
expect(result.total_nodes).toBe(7);
expect(seen).toEqual([half, payload.length]);
});
});
describe("clampNodeBudget", () => {
it("snaps to 5k steps within the 5k..10M range", () => {
expect(GRAPH_NODE_BUDGET_STEP).toBe(5000);
expect(GRAPH_NODE_BUDGET_MAX).toBe(10_000_000);
expect(clampNodeBudget(5000)).toBe(5000);
expect(clampNodeBudget(12345)).toBe(10000);
expect(clampNodeBudget(12501)).toBe(15000);
expect(clampNodeBudget(0)).toBe(5000);
expect(clampNodeBudget(-500)).toBe(5000);
expect(clampNodeBudget(99_999_999)).toBe(10_000_000);
expect(clampNodeBudget(Number.NaN)).toBe(GRAPH_RENDER_NODE_LIMIT);
});
});
+131
View File
@@ -0,0 +1,131 @@
import { useCallback, useState } from "react";
import type { GraphData } from "../lib/types";
export interface LoadProgress {
receivedBytes: number;
totalBytes: number | null;
}
interface UseGraphDataResult {
data: GraphData | null;
loading: boolean;
error: string | null;
progress: LoadProgress;
fetchOverview: (
project: string,
maxNodes?: number,
graph?: "code" | "missed",
) => void;
fetchDetail: (project: string, centerNode: string) => void;
}
/* Node budget: how many nodes the layout endpoint is asked for. The default
* keeps first paint fast; the user can raise it in 5k steps up to the hard
* ceiling (mirrors HARD_MAX_NODES in src/ui/layout3d.c). Edges always follow
* the budget — the server returns every edge between the loaded nodes. */
export const GRAPH_RENDER_NODE_LIMIT = 5000;
export const GRAPH_NODE_BUDGET_STEP = 5000;
export const GRAPH_NODE_BUDGET_MAX = 10_000_000;
export function clampNodeBudget(value: number): number {
if (!Number.isFinite(value)) return GRAPH_RENDER_NODE_LIMIT;
const stepped =
Math.round(value / GRAPH_NODE_BUDGET_STEP) * GRAPH_NODE_BUDGET_STEP;
if (stepped < GRAPH_NODE_BUDGET_STEP) return GRAPH_NODE_BUDGET_STEP;
if (stepped > GRAPH_NODE_BUDGET_MAX) return GRAPH_NODE_BUDGET_MAX;
return stepped;
}
/** Which graph to lay out: the code graph (default) or the missed graph —
* only files the indexer could not fully cover, as their file structure. */
export type GraphVariant = "code" | "missed";
export async function fetchLayout(
project: string,
maxNodes = GRAPH_RENDER_NODE_LIMIT,
onProgress?: (progress: LoadProgress) => void,
graph: GraphVariant = "code",
): Promise<GraphData> {
const params = new URLSearchParams({ project, max_nodes: String(maxNodes) });
if (graph === "missed") params.set("graph", "missed");
const res = await fetch(`/api/layout?${params}`);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error ?? `HTTP ${res.status}`);
}
/* Stream the body when possible so large budgets show live download
* progress instead of a silent stall. */
if (!res.body || !onProgress) {
return res.json();
}
const lengthHeader = res.headers.get("content-length");
const totalBytes = lengthHeader ? parseInt(lengthHeader, 10) || null : null;
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let receivedBytes = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
receivedBytes += value.length;
onProgress({ receivedBytes, totalBytes });
}
const merged = new Uint8Array(receivedBytes);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
return JSON.parse(new TextDecoder().decode(merged));
}
const NO_PROGRESS: LoadProgress = { receivedBytes: 0, totalBytes: null };
export function useGraphData(): UseGraphDataResult {
const [data, setData] = useState<GraphData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<LoadProgress>(NO_PROGRESS);
const fetchOverview = useCallback(
async (project: string, maxNodes?: number, graph: GraphVariant = "code") => {
setLoading(true);
setError(null);
setProgress(NO_PROGRESS);
try {
const result = await fetchLayout(project, maxNodes, setProgress, graph);
setData(result);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to fetch layout");
} finally {
setLoading(false);
}
},
[],
);
const fetchDetail = useCallback(
async (project: string, _centerNode: string) => {
setLoading(true);
setError(null);
setProgress(NO_PROGRESS);
try {
/* TODO: detail level with center_node filtering */
const result = await fetchLayout(project, undefined, setProgress);
setData(result);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to fetch layout");
} finally {
setLoading(false);
}
},
[],
);
return { data, loading, error, progress, fetchOverview, fetchDetail };
}
+56
View File
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from "react";
import { callTool } from "../api/rpc";
import type { Project, SchemaInfo } from "../lib/types";
interface ProjectInfo {
project: Project;
schema: SchemaInfo | null;
}
interface UseProjectsResult {
projects: ProjectInfo[];
loading: boolean;
error: string | null;
refresh: () => void;
}
export function useProjects(): UseProjectsResult {
const [projects, setProjects] = useState<ProjectInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchProjects = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await callTool<{ projects: Project[] }>("list_projects");
const list = result.projects ?? [];
/* Fetch schema for each project */
const infos: ProjectInfo[] = await Promise.all(
list.map(async (p) => {
try {
const schema = await callTool<SchemaInfo>("get_graph_schema", {
project: p.name,
});
return { project: p, schema };
} catch {
return { project: p, schema: null };
}
}),
);
setProjects(infos);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to fetch projects");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
return { projects, loading, error, refresh: fetchProjects };
}
+63
View File
@@ -0,0 +1,63 @@
/* Node label → color mapping for sidebar/tooltips (structural meaning) */
const LABEL_COLORS: Record<string, string> = {
Project: "#e11d48",
Package: "#f97316",
Module: "#f97316",
Folder: "#22c55e",
File: "#3b82f6",
Class: "#a855f7",
Interface: "#a855f7",
Function: "#06b6d4",
Method: "#06b6d4",
Route: "#eab308",
Variable: "#64748b",
};
const DEFAULT_COLOR = "#94a3b8";
export function colorForLabel(label: string): string {
return LABEL_COLORS[label] ?? DEFAULT_COLOR;
}
/* Dead-code status → color (matches layout3d.c status strings).
* dead zero callers + zero usages, not entry/test/exported
* single exactly one caller
* entry entry points / routes
* test test code
* normal healthy (>=2 callers)
* exported/structural → dimmed grey (not dead-code candidates) */
const STATUS_COLORS: Record<string, string> = {
dead: "#ef4444",
single: "#f97316",
entry: "#3b82f6",
test: "#a855f7",
normal: "#22c55e",
exported: "#475569",
structural: "#334155",
};
const STATUS_DEFAULT = "#334155";
export function colorForStatus(status?: string): string {
return status ? (STATUS_COLORS[status] ?? STATUS_DEFAULT) : STATUS_DEFAULT;
}
export const STATUS_LEGEND: { status: string; label: string; color: string }[] = [
{ status: "dead", label: "Dead (0 callers)", color: STATUS_COLORS.dead },
{ status: "single", label: "One caller", color: STATUS_COLORS.single },
{ status: "entry", label: "Entry / route", color: STATUS_COLORS.entry },
{ status: "test", label: "Test", color: STATUS_COLORS.test },
{ status: "normal", label: "Normal", color: STATUS_COLORS.normal },
];
/* Stellar spectral type legend (for the graph view) */
export const STELLAR_LEGEND = [
{ type: "O (Blue Giant)", color: "#80a0ff", description: "50+ connections" },
{ type: "B (Blue-White)", color: "#c0d0ff", description: "26-50 connections" },
{ type: "A (White)", color: "#e8e8ff", description: "13-25 connections" },
{ type: "F (Yellow-White)", color: "#fff0c0", description: "7-12 connections" },
{ type: "G (Yellow/Sun)", color: "#ffe080", description: "4-6 connections" },
{ type: "K (Orange)", color: "#ffa060", description: "2-3 connections" },
{ type: "M (Red Dwarf)", color: "#ff6050", description: "0-1 connections" },
];
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
edgeIntensityScale,
bloomIntensityScale,
nodeBoostScale,
nodeGlowBoost,
clampDisplaySettings,
DEFAULT_DISPLAY_SETTINGS,
EDGE_REFERENCE_COUNT,
NODE_REFERENCE_COUNT,
} from "./density";
/* Parse "#rrggbb" to 0..1 channels, matching how the renderer feeds colors. */
function rgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [((n >> 16) & 0xff) / 255, ((n >> 8) & 0xff) / 255, (n & 0xff) / 255];
}
const glow = (hex: string) => nodeGlowBoost(...rgb(hex));
describe("edgeIntensityScale", () => {
it("is 1 at or below the reference edge count", () => {
expect(edgeIntensityScale(0)).toBe(1);
expect(edgeIntensityScale(EDGE_REFERENCE_COUNT)).toBe(1);
});
it("shrinks monotonically as edges grow, bounded below", () => {
const a = edgeIntensityScale(10_000);
const b = edgeIntensityScale(80_000);
expect(a).toBeLessThan(1);
expect(b).toBeLessThan(a);
expect(b).toBeGreaterThanOrEqual(0.05);
/* Never collapses to zero even at absurd counts */
expect(edgeIntensityScale(50_000_000)).toBeGreaterThanOrEqual(0.05);
});
it("keeps accumulated brightness roughly flat (scale ~ 1/sqrt(n))", () => {
/* 4x the edges → each ~half as bright → similar total glow */
const ref = edgeIntensityScale(EDGE_REFERENCE_COUNT * 4);
expect(ref).toBeCloseTo(0.5, 5);
});
});
describe("bloomIntensityScale", () => {
it("stays full through the reference count (preserves the glow), then eases", () => {
/* A whole mid-size repo (≤25k nodes) keeps full bloom — the wow effect. */
expect(bloomIntensityScale(NODE_REFERENCE_COUNT)).toBe(1);
expect(bloomIntensityScale(20_000)).toBe(1);
/* Only very large clouds ease back, and never below the floor. */
expect(bloomIntensityScale(100_000)).toBeLessThan(1);
expect(bloomIntensityScale(10_000_000)).toBeGreaterThanOrEqual(0.7);
});
});
describe("nodeBoostScale", () => {
it("keeps node halos bright through the reference count, then eases gently", () => {
expect(nodeBoostScale(NODE_REFERENCE_COUNT)).toBe(1);
expect(nodeBoostScale(20_000)).toBe(1);
const big = nodeBoostScale(100_000);
expect(big).toBeGreaterThan(0.8);
expect(big).toBeLessThan(1);
/* Floored — never fully flat, even at millions of nodes. */
expect(nodeBoostScale(10_000_000)).toBeGreaterThanOrEqual(0.8);
});
});
describe("nodeGlowBoost", () => {
/* Star-class palette from lib/colors.ts STAR_LEGEND */
const BLUE_GIANT = "#80a0ff"; // 50+ connections (hubs)
const WHITE = "#e8e8ff"; // mid degree
const YELLOW = "#ffe080"; // mid degree
const RED_DWARF = "#ff6050"; // leaves
it("makes blue hubs glow the most", () => {
expect(glow(BLUE_GIANT)).toBeGreaterThan(glow(RED_DWARF));
expect(glow(BLUE_GIANT)).toBeGreaterThan(glow(WHITE));
expect(glow(BLUE_GIANT)).toBeGreaterThan(glow(YELLOW));
});
it("gives red leaves a modest boost — above white/yellow, below blue", () => {
expect(glow(RED_DWARF)).toBeGreaterThan(glow(WHITE));
expect(glow(RED_DWARF)).toBeGreaterThan(glow(YELLOW));
expect(glow(RED_DWARF)).toBeLessThan(glow(BLUE_GIANT));
});
it("keeps white/yellow low so they don't blow out the bloom", () => {
/* Old formula gave white ~2.0×; it must now be well under the blue hub. */
expect(glow(WHITE)).toBeLessThan(1.7);
expect(glow(YELLOW)).toBeLessThan(1.7);
});
it("never dims a node (multiplier ≥ 1)", () => {
for (const hex of [BLUE_GIANT, WHITE, YELLOW, RED_DWARF, "#06b6d4", "#a855f7"]) {
expect(glow(hex)).toBeGreaterThanOrEqual(1);
}
});
});
describe("clampDisplaySettings", () => {
it("clamps each setting to its range and fills defaults", () => {
expect(clampDisplaySettings({})).toEqual(DEFAULT_DISPLAY_SETTINGS);
expect(
clampDisplaySettings({ edgeBrightness: 99, nodeGlow: -5, bloom: 1.5 }),
).toEqual({ edgeBrightness: 3, nodeGlow: 0, bloom: 1.5 });
expect(clampDisplaySettings({ bloom: Number.NaN }).bloom).toBe(
DEFAULT_DISPLAY_SETTINGS.bloom,
);
});
});
+134
View File
@@ -0,0 +1,134 @@
/* Visual density compensation.
*
* The white-blob-at-scale failure is dominated by EDGES: they blend
* additively and a 15k-node graph carries ~80k long lines crossing the
* center, so their glow stacks into an opaque wash. Nodes are far fewer and
* discrete — their bloom halos are the whole "wow", so we keep those bright.
*
* Strategy: dim edges hard (they cause the blob), keep nodes and bloom near
* full so the bright stars still pop; only ease nodes/bloom back on genuinely
* huge clouds where even discrete points overlap. Highlighted elements are
* never scaled — a selection stays bright against the dimmed rest. */
/* Edge counts at/below this render exactly as before. */
export const EDGE_REFERENCE_COUNT = 2500;
const EDGE_MIN_SCALE = 0.05;
export function edgeIntensityScale(edgeCount: number): number {
if (edgeCount <= EDGE_REFERENCE_COUNT) return 1;
/* ~1/sqrt(n): 4× the edges → each ~half as bright → total glow ~flat. */
return Math.max(EDGE_MIN_SCALE, Math.sqrt(EDGE_REFERENCE_COUNT / edgeCount));
}
/* Node glow and bloom stay at full strength up to here — this covers the
* common "load the whole repo" case (tens of thousands of nodes) so the
* bright-star look is preserved. */
export const NODE_REFERENCE_COUNT = 25000;
/* …then ease gently toward these floors as the cloud grows past the fade end,
* where even discrete point sprites start to overlap and over-bloom. */
const NODE_FADE_END = 250000;
const BLOOM_FLOOR = 0.7;
const NODE_BOOST_FLOOR = 0.8;
function fadeFactor(nodeCount: number): number {
if (nodeCount <= NODE_REFERENCE_COUNT) return 0;
return Math.min(
1,
(nodeCount - NODE_REFERENCE_COUNT) / (NODE_FADE_END - NODE_REFERENCE_COUNT),
);
}
export function bloomIntensityScale(nodeCount: number): number {
return 1 - fadeFactor(nodeCount) * (1 - BLOOM_FLOOR);
}
/* Per-node glow boost: full up to the reference count, then a gentle,
* high-floored fade so large clouds don't merge into mush while moderate
* graphs keep their halos. */
export function nodeBoostScale(nodeCount: number): number {
return 1 - fadeFactor(nodeCount) * (1 - NODE_BOOST_FLOOR);
}
/* Colour-aware glow multiplier applied to each node before bloom.
*
* Nodes are coloured as star classes by degree: blue giants (high-degree
* hubs) → white/yellow (mid) → red dwarfs (leaves). Bloom is luminance-
* thresholded, and blue has a tiny luminance weight, so a naive
* brightness-based boost makes white/yellow blow out while blue and red stay
* flat. Instead we boost by *channel dominance*: a blue-dominant node gets the
* strongest boost (the important hubs shine brightest), a red-dominant node a
* modest one, and white/yellow — which need no help to bloom — the least.
*
* r, g, b are 0..1 colour channels. Returns a multiplier ≥ 1. */
const GLOW_BASE = 1.35;
const GLOW_BLUE_GAIN = 2.4;
const GLOW_RED_GAIN = 0.9;
export function nodeGlowBoost(r: number, g: number, b: number): number {
/* Blue that exceeds both red and green → cool hub. Red that exceeds both
* green and blue → warm leaf (green cutoff keeps yellow/orange out of the
* red term so they are not boosted). */
const blueness = Math.max(0, b - Math.max(r, g));
const redness = Math.max(0, r - Math.max(g, b));
return GLOW_BASE + blueness * GLOW_BLUE_GAIN + redness * GLOW_RED_GAIN;
}
/* ── Manual display settings ──────────────────────────────────── *
* User-facing multipliers layered ON TOP of the adaptive scales above:
* the adaptive scale picks a sane default for the current density, the
* sliders let the user push contrast/brightness around it. Persisted
* globally (a display preference, not a per-project fact). */
export interface DisplaySettings {
/** Edge brightness multiplier (0.13, default 1). */
edgeBrightness: number;
/** Node glow-boost multiplier (02, default 1). */
nodeGlow: number;
/** Bloom intensity multiplier (02, default 1). */
bloom: number;
}
export const DEFAULT_DISPLAY_SETTINGS: DisplaySettings = {
edgeBrightness: 1,
nodeGlow: 1,
bloom: 1,
};
export const DISPLAY_LIMITS = {
edgeBrightness: { min: 0.1, max: 3 },
nodeGlow: { min: 0, max: 2 },
bloom: { min: 0, max: 2 },
} as const;
const DISPLAY_STORAGE_KEY = "cbm-display";
function clampSetting(key: keyof DisplaySettings, value: unknown): number {
const { min, max } = DISPLAY_LIMITS[key];
const n = typeof value === "number" ? value : Number.NaN;
if (!Number.isFinite(n)) return DEFAULT_DISPLAY_SETTINGS[key];
return Math.min(max, Math.max(min, n));
}
export function clampDisplaySettings(
raw: Partial<DisplaySettings>,
): DisplaySettings {
return {
edgeBrightness: clampSetting("edgeBrightness", raw.edgeBrightness),
nodeGlow: clampSetting("nodeGlow", raw.nodeGlow),
bloom: clampSetting("bloom", raw.bloom),
};
}
export function loadDisplaySettings(): DisplaySettings {
try {
const raw = localStorage.getItem(DISPLAY_STORAGE_KEY);
if (raw) return clampDisplaySettings(JSON.parse(raw));
} catch { /* ignore */ }
return DEFAULT_DISPLAY_SETTINGS;
}
export function saveDisplaySettings(settings: DisplaySettings) {
try {
localStorage.setItem(DISPLAY_STORAGE_KEY, JSON.stringify(settings));
} catch { /* ignore */ }
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { detectLanguage, messages } from "./i18n";
describe("i18n", () => {
it("detects Chinese from Accept-Language and falls back to English", () => {
expect(detectLanguage("zh-CN,zh;q=0.9,en;q=0.8")).toBe("zh");
expect(detectLanguage("de-DE,de;q=0.9")).toBe("en");
});
it("keeps UI chrome messages in the catalog", () => {
expect(messages.zh.tabs.projects).toBe("项目");
expect(messages.zh.index.newIndex).toBe("新建索引");
expect(messages.en.index.repositoryPath).toBe("Repository path");
});
});
+205
View File
@@ -0,0 +1,205 @@
import { useEffect, useState } from "react";
export type UiLanguage = "en" | "zh";
export const messages = {
en: {
tabs: {
graph: "Graph",
projects: "Projects",
control: "Control",
},
common: {
cancel: "Cancel",
refresh: "Refresh",
loading: "Loading...",
save: "Save",
saving: "Saving...",
delete: "Delete",
noMatches: "No matches",
dismiss: "Dismiss",
},
graph: {
selectedLabel: "Graph",
search: "Search...",
clearSelection: "Clear selection",
folders: "Folders",
},
projects: {
indexedProjects: "Indexed Projects",
noIndexedProjects: "No indexed projects",
indexFirstRepository: "Index your first repository",
viewGraph: "View Graph",
nodes: "nodes",
edges: "edges",
deleteTitle: "Delete index",
deleteConfirm: (name: string) => `Delete index for "${name}"?`,
healthHealthy: "Database healthy",
healthMissing: "Database missing",
healthCorrupt: "Database unhealthy",
healthChecking: "Checking...",
indexingInProgress: "Indexing in progress",
indexingFailed: "Indexing failed",
},
index: {
newIndex: "New Index",
selectRepositoryFolder: "Select Repository Folder",
instructions: "Navigate to the project root and click \"Index This Folder\".",
repositoryPath: "Repository path",
projectName: "Project ID (optional — permanent, cannot be renamed)",
projectNamePlaceholder: "Derived from folder name if blank",
projectNameHelp: "Becomes the database name and query prefix. Leave blank to derive it from the path.",
filterFolders: "Filter folders",
noSubdirectories: "No subdirectories",
indexThisFolder: "Index This Folder",
starting: "Starting...",
browseRoot: (path: string) => `Browse ${path}`,
indexDirectory: (name: string) => `Index ${name}`,
},
adr: {
title: "Architecture Decision Record",
lastUpdated: "Last updated",
},
control: {
panel: "Control Panel",
totalCpu: "Total CPU",
totalRam: "Total RAM",
processes: "Processes",
selfRam: "Self RAM",
activeProcesses: "Active Processes",
processLogs: "Process Logs",
noProcesses: "No processes found",
noLogs: "No logs yet",
kill: "Kill",
thisProcess: "THIS",
uptime: "Uptime",
killConfirm: (pid: number) => `Kill process ${pid}?`,
},
},
zh: {
tabs: {
graph: "图谱",
projects: "项目",
control: "控制",
},
common: {
cancel: "取消",
refresh: "刷新",
loading: "加载中...",
save: "保存",
saving: "保存中...",
delete: "删除",
noMatches: "无匹配结果",
dismiss: "关闭",
},
graph: {
selectedLabel: "图谱",
search: "搜索...",
clearSelection: "清除选择",
folders: "目录",
},
projects: {
indexedProjects: "已索引项目",
noIndexedProjects: "暂无已索引项目",
indexFirstRepository: "索引第一个仓库",
viewGraph: "查看图谱",
nodes: "节点",
edges: "边",
deleteTitle: "删除索引",
deleteConfirm: (name: string) => `删除 "${name}" 的索引?`,
healthHealthy: "数据库正常",
healthMissing: "数据库缺失",
healthCorrupt: "数据库异常",
healthChecking: "检查中...",
indexingInProgress: "正在索引",
indexingFailed: "索引失败",
},
index: {
newIndex: "新建索引",
selectRepositoryFolder: "选择仓库目录",
instructions: "导航到项目根目录,然后点击“索引此目录”。",
repositoryPath: "仓库路径",
projectName: "项目 ID(可选,永久且不可重命名)",
projectNamePlaceholder: "留空则从路径派生",
projectNameHelp: "将作为数据库名称与查询前缀;留空则从路径派生。",
filterFolders: "筛选目录",
noSubdirectories: "没有子目录",
indexThisFolder: "索引此目录",
starting: "启动中...",
browseRoot: (path: string) => `浏览 ${path}`,
indexDirectory: (name: string) => `索引 ${name}`,
},
adr: {
title: "架构决策记录",
lastUpdated: "最后更新",
},
control: {
panel: "控制面板",
totalCpu: "总 CPU",
totalRam: "总内存",
processes: "进程",
selfRam: "自身内存",
activeProcesses: "活动进程",
processLogs: "进程日志",
noProcesses: "未找到进程",
noLogs: "暂无日志",
kill: "结束",
thisProcess: "本进程",
uptime: "运行时间",
killConfirm: (pid: number) => `结束进程 ${pid}`,
},
},
} as const;
export type UiMessages = (typeof messages)[UiLanguage];
export function detectLanguage(acceptLanguage?: string | null, override?: string | null): UiLanguage {
if (override === "zh" || override === "en") return override;
if (!acceptLanguage) return "en";
const normalized = acceptLanguage.toLowerCase();
return normalized.includes("zh-cn") || normalized.includes("zh") ? "zh" : "en";
}
let cachedLanguage: UiLanguage = "en";
let languageLoaded = false;
let languageRequest: Promise<UiLanguage> | null = null;
const languageListeners = new Set<(lang: UiLanguage) => void>();
function loadUiLanguage(): Promise<UiLanguage> {
if (languageLoaded) return Promise.resolve(cachedLanguage);
if (languageRequest) return languageRequest;
languageRequest = fetch("/api/ui-config")
.then((r) => r.json())
.then((data) => detectLanguage(null, data?.lang))
.catch(() => detectLanguage(navigator.language))
.then((lang) => {
cachedLanguage = lang;
languageLoaded = true;
for (const listener of languageListeners) listener(lang);
return lang;
})
.finally(() => {
languageRequest = null;
});
return languageRequest;
}
export function useUiMessages(): UiMessages {
const [lang, setLang] = useState<UiLanguage>(cachedLanguage);
useEffect(() => {
let cancelled = false;
languageListeners.add(setLang);
void loadUiLanguage().then((nextLang) => {
if (!cancelled) setLang(nextLang);
});
return () => {
cancelled = true;
languageListeners.delete(setLang);
};
}, []);
return messages[lang];
}
+92
View File
@@ -0,0 +1,92 @@
/* Graph data types matching the C layout3d.c JSON output */
export interface GraphNode {
id: number;
x: number;
y: number;
z: number;
label: string;
name: string;
file_path?: string;
qualified_name?: string;
start_line?: number;
end_line?: number;
size: number;
color: string;
/* Dead-code classification from the backend layout (layout3d.c). */
status?: NodeStatus;
in_calls?: number;
}
export type NodeStatus =
| "dead"
| "single"
| "entry"
| "test"
| "exported"
| "normal"
| "structural";
/* Git remote metadata for building GitHub deep-links (/api/repo-info). */
export interface RepoInfo {
root_path: string;
branch: string;
remote_url: string;
web_base: string; /* e.g. github.com/<org>/<repo> */
blob_base: string; /* e.g. github.com/<org>/<repo>/blob/<branch> */
}
export interface GraphEdge {
source: number;
target: number;
type: string;
}
export interface LinkedProject {
project: string;
nodes: GraphNode[];
edges: GraphEdge[];
offset: { x: number; y: number; z: number };
cross_edges: GraphEdge[];
}
/* Missed-graph skeleton (#963): the file structure of files the indexer
* could not fully cover, laid out as a satellite cluster beside the code
* galaxy (server-computed offset, same shape as LinkedProject's). */
export interface MissedGraph {
nodes: GraphNode[];
edges: GraphEdge[];
offset: { x: number; y: number; z: number };
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
total_nodes: number;
linked_projects?: LinkedProject[];
missed_graph?: MissedGraph;
}
export interface Project {
name: string;
root_path: string;
indexed_at: string;
}
export interface SchemaInfo {
node_labels: { label: string; count: number }[];
edge_types: { type: string; count: number }[];
total_nodes: number;
total_edges: number;
}
export type TabId = "graph" | "stats" | "control";
export interface ProcessInfo {
pid: number;
cpu: number;
rss_mb: number;
elapsed: string;
command: string;
is_self: boolean;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/globals.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+78
View File
@@ -0,0 +1,78 @@
@import "tailwindcss";
@theme inline {
--color-background: #0a161a;
--color-foreground: #e0eded;
--color-card: #0e2028;
--color-card-foreground: #e0eded;
--color-popover: #0e2028;
--color-popover-foreground: #e0eded;
--color-primary: #1DA27E;
--color-primary-foreground: #0a161a;
--color-secondary: #0f1e24;
--color-secondary-foreground: #b0d4d4;
--color-muted: #0f1e24;
--color-muted-foreground: #6a9e9e;
--color-accent: #1C8585;
--color-accent-foreground: #e0eded;
--color-destructive: #e05252;
--color-destructive-foreground: #0a161a;
--color-border: #1a3a4030;
--color-input: #0c1c2260;
--color-ring: #1DA27E;
--radius: 0.75rem;
--color-sidebar: #0c1a20;
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
}
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: var(--font-sans);
background: var(--color-background);
color: var(--color-foreground);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Graph loading constellation ─────────────────────────────── */
.graph-loader-node {
fill: #22d3ee;
animation: graph-loader-pulse 2.1s ease-in-out infinite;
transform-box: fill-box;
transform-origin: center;
}
.graph-loader-edge {
stroke: #22d3ee;
stroke-opacity: 0.35;
stroke-width: 1.2;
stroke-dasharray: 90;
stroke-dashoffset: 90;
animation: graph-loader-draw 2.1s ease-in-out infinite;
}
@keyframes graph-loader-pulse {
0%, 100% { opacity: 0.25; transform: scale(0.8); }
35% { opacity: 1; transform: scale(1.15); }
70% { opacity: 0.45; transform: scale(0.95); }
}
@keyframes graph-loader-draw {
0% { stroke-dashoffset: 90; stroke-opacity: 0; }
40% { stroke-dashoffset: 0; stroke-opacity: 0.5; }
80%, 100% { stroke-dashoffset: 0; stroke-opacity: 0.15; }
}
@media (prefers-reduced-motion: reduce) {
.graph-loader-node,
.graph-loader-edge {
animation: none;
}
.graph-loader-node { opacity: 0.8; }
.graph-loader-edge { stroke-dashoffset: 0; stroke-opacity: 0.35; }
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+1
View File
@@ -0,0 +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.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.test.ts","./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.test.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.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
+35
View File
@@ -0,0 +1,35 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
test: {
environment: "jsdom",
globals: true,
},
build: {
outDir: "dist",
assetsDir: "assets",
sourcemap: false,
rollupOptions: {
output: {
manualChunks: undefined,
},
},
},
server: {
port: 5173,
proxy: {
"/rpc": "http://127.0.0.1:9749",
"/api": "http://127.0.0.1:9749",
},
},
});