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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

158 changes: 150 additions & 8 deletions frontend/components/graph/GraphCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,123 @@
import { CodeEdge, CodeNode } from "@/lib/types";
import { useEffect, useRef, useImperativeHandle, forwardRef } from "react";
import { useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
import cytoscape from "cytoscape";
import fcose from "cytoscape-fcose";
import { toElements } from "./cytoscopeUtils";
import { getCytoscapeStyles, getLayoutConfig } from "./cytoscapeConfig";

cytoscape.use(fcose);

// ─── Tooltip ──────────────────────────────────────────────────────────────────

interface TooltipInfo {
x: number;
y: number;
type: string;
filePath: string;
score: number;
hasState: boolean;
hooks: string[];
children: string[];
}

const TOOLTIP_TYPE_COLORS: Record<string, { bg: string; text: string; border: string }> = {
COMPONENT: { bg: "#2dd4bf18", text: "#2dd4bf", border: "#2dd4bf30" },
HOOK: { bg: "#c084fc18", text: "#c084fc", border: "#c084fc30" },
FUNCTION: { bg: "#60a5fa18", text: "#60a5fa", border: "#60a5fa30" },
STATE_STORE: { bg: "#fb923c18", text: "#fb923c", border: "#fb923c30" },
UTILITY: { bg: "#94a3b818", text: "#94a3b8", border: "#94a3b830" },
FILE: { bg: "#f472b618", text: "#f472b6", border: "#f472b630" },
GHOST: { bg: "#6b728018", text: "#6b7280", border: "#6b728030" },
ROUTE: { bg: "#818cf818", text: "#818cf8", border: "#818cf830" },
TEST: { bg: "#f9731618", text: "#f97316", border: "#f9731630" },
STORY: { bg: "#a78bfa18", text: "#a78bfa", border: "#a78bfa30" },
};

function NodeTooltip({ t }: { t: TooltipInfo }) {
const colors = TOOLTIP_TYPE_COLORS[t.type] ?? { bg: "#21262d", text: "#8b949e", border: "#30363d" };
const shortPath = t.filePath.split("/").slice(-3).join("/");
const hasExtra = t.hasState || t.hooks.length > 0 || t.children.length > 0;

return (
<div
className="absolute pointer-events-none z-50"
style={{ left: t.x + 16, top: t.y - 8, transform: "translateY(-100%)" }}
>
<div
className="rounded-lg px-3 py-2.5 text-xs shadow-xl"
style={{
background: "#161b22",
border: "1px solid #30363d",
minWidth: 180,
maxWidth: 280,
}}
>
{/* Type badge */}
<span
className="inline-block mb-1.5 px-1.5 py-0.5 rounded text-[10px] font-medium uppercase tracking-wide"
style={{ background: colors.bg, color: colors.text, border: `1px solid ${colors.border}` }}
>
{t.type.replace("_", " ")}
</span>

{/* File path */}
<div
className="font-mono truncate"
style={{ color: "#2dd4bf" }}
title={t.filePath}
>
{shortPath}
</div>

{/* Divider */}
{hasExtra && (
<div className="my-2" style={{ borderTop: "1px solid #21262d" }} />
)}

{/* Hooks */}
{t.hooks.length > 0 && (
<div className="font-mono truncate mb-1" style={{ color: "#6e7681", fontSize: "10px" }}>
{t.hooks.slice(0, 4).join(", ")}{t.hooks.length > 4 ? " …" : ""}
</div>
)}

{/* Local state pill */}
{t.hasState && (
<div className="flex items-center gap-1.5 mb-1" style={{ color: "#8b949e" }}>
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: "#60a5fa" }} />
Local state
</div>
)}

{/* Direct children */}
{t.children.length > 0 && (
<div className="mt-1">
<div className="uppercase tracking-wide mb-1" style={{ color: "#484f58", fontSize: "10px" }}>
Children
</div>
<div className="flex flex-wrap gap-1">
{t.children.slice(0, 8).map((name) => (
<span
key={name}
className="px-1.5 py-0.5 rounded font-mono"
style={{ background: "#21262d", color: "#8b949e", fontSize: "10px" }}
>
{name}
</span>
))}
{t.children.length > 8 && (
<span className="px-1.5 py-0.5" style={{ color: "#484f58", fontSize: "10px" }}>
+{t.children.length - 8} more
</span>
)}
</div>
</div>
)}
</div>
</div>
);
}

// ─── Exposed handle ───────────────────────────────────────────────────────────

export interface GraphCanvasHandle {
Expand All @@ -30,6 +141,7 @@ const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
({ nodes, edges, onNodeClick }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const cyRef = useRef<cytoscape.Core | null>(null);
const [tooltip, setTooltip] = useState<TooltipInfo | null>(null);

useImperativeHandle(ref, () => ({
focusNode(nodeId: string) {
Expand Down Expand Up @@ -148,8 +260,35 @@ const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
e.target.style("z-index", 9999);
onNodeClick?.(e.target.id());
});
cy.on("mouseover", "node", (e) => e.target.addClass("hover"));
cy.on("mouseout", "node", (e) => e.target.removeClass("hover"));
cy.on("mouseover", "node", (e) => {
e.target.addClass("hover");
const pos = e.target.renderedPosition() as { x: number; y: number };
const meta = e.target.data("metadata") as Record<string, unknown> | undefined;
const hooks = Array.isArray(meta?.hooks) ? (meta!.hooks as string[]) : [];

const children: string[] = [];
e.target.outgoers("edge").forEach((edge: cytoscape.EdgeSingular) => {
if (edge.data("type") === "PROP_PASS") {
children.push(edge.target().data("label") as string);
}
});

setTooltip({
x: pos.x,
y: pos.y,
type: e.target.data("type") as string,
filePath: e.target.data("filePath") as string,
score: e.target.data("score") as number,
hasState: !!(meta?.hasState),
hooks,
children,
});
});
cy.on("mouseout", "node", (e) => {
e.target.removeClass("hover");
setTooltip(null);
});
cy.on("pan zoom", () => setTooltip(null));
cy.on("tap", (e) => {
if (e.target === cy) {
cy.nodes().style("z-index", 1);
Expand All @@ -164,11 +303,14 @@ const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
}, []);

return (
<div
ref={containerRef}
className="w-full h-full"
style={{ background: "#13191f" }}
/>
<div className="relative w-full h-full">
<div
ref={containerRef}
className="w-full h-full"
style={{ background: "#13191f" }}
/>
{tooltip && <NodeTooltip t={tooltip} />}
</div>
);
},
);
Expand Down