chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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"}
|
||||
@@ -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>
|
||||
Generated
+6413
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/* TabBar moved inline into App.tsx header — this file kept for compatibility */
|
||||
export {};
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.1–3, default 1). */
|
||||
edgeBrightness: number;
|
||||
/** Node glow-boost multiplier (0–2, default 1). */
|
||||
nodeGlow: number;
|
||||
/** Bloom intensity multiplier (0–2, 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 */ }
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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; }
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user