"use client"; import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, } from "react"; import { CircleAlert, ExternalLink, Link2, LoaderCircle, Maximize2, ZoomIn, ZoomOut, } from "lucide-react"; import { getGraphSnapshot, readWorkspaceFile } from "../api"; import { useI18n } from "../i18n"; import { useWorkspaceStore } from "../store"; import type { GraphSnapshot, MemoryGraphRoot } from "../types"; import { edgePath, GRAPH_HEIGHT, GRAPH_WIDTH, graphBelowRoot, INNER_RING_RADIUS, layoutGraph, nodeLabel, OUTER_RING_RADIUS, reciprocalEdgeKeys, shortNodeLabel, type PositionedGraphNode, } from "./memory-graph"; import styles from "./memory-graph.module.css"; const AUTO_REFRESH_MS = 10_000; interface NodeOffset { x: number; y: number; } interface DragSession { pointerId: number; nodeId: string; start: NodeOffset; base: NodeOffset; } function pointerPosition( event: ReactPointerEvent, zoom: number, ): NodeOffset { const bounds = event.currentTarget.ownerSVGElement?.getBoundingClientRect(); if (!bounds?.width || !bounds.height) return { x: event.clientX, y: event.clientY }; const viewX = ((event.clientX - bounds.left) / bounds.width) * GRAPH_WIDTH; const viewY = ((event.clientY - bounds.top) / bounds.height) * GRAPH_HEIGHT; return { x: GRAPH_WIDTH / 2 + (viewX - GRAPH_WIDTH / 2) / zoom, y: GRAPH_HEIGHT / 2 + (viewY - GRAPH_HEIGHT / 2) / zoom, }; } export default function MemoryGraphView({ root }: { root: MemoryGraphRoot }) { const { t } = useI18n(); const [snapshot, setSnapshot] = useState(); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); const [selectedId, setSelectedId] = useState(""); const [hoveredId, setHoveredId] = useState(""); const [zoom, setZoom] = useState(1); const [offsets, setOffsets] = useState>({}); const [draggingId, setDraggingId] = useState(""); const dragSession = useRef(undefined); const didDrag = useRef(false); const tabs = useWorkspaceStore((state) => state.tabs); const openMarkdown = useWorkspaceStore((state) => state.openMarkdown); const hydrateMarkdown = useWorkspaceStore((state) => state.hydrateMarkdown); const failMarkdown = useWorkspaceStore((state) => state.failMarkdown); useEffect(() => { let mounted = true; let fetching = false; const load = async () => { if (!mounted || fetching) return; fetching = true; try { const next = await getGraphSnapshot(); if (!mounted) return; setSnapshot(next); setError(false); setSelectedId((current) => next.nodes.some((node) => node.id === current) ? current : "", ); } catch { if (mounted) setError(true); } finally { fetching = false; if (mounted) setLoading(false); } }; void load(); const timer = window.setInterval(() => { if (!document.hidden) void load(); }, AUTO_REFRESH_MS); const onVisibilityChange = () => { if (!document.hidden) void load(); }; document.addEventListener("visibilitychange", onVisibilityChange); return () => { mounted = false; window.clearInterval(timer); document.removeEventListener("visibilitychange", onVisibilityChange); }; }, []); const graphSnapshot = useMemo( () => (snapshot ? graphBelowRoot(snapshot, root) : undefined), [root, snapshot], ); const baseGraph = useMemo( () => layoutGraph(graphSnapshot || { version: 1, nodes: [], edges: [] }), [graphSnapshot], ); const graph = useMemo(() => { const nodes = baseGraph.nodes.map((node) => ({ ...node, x: node.x + (offsets[node.id]?.x || 0), y: node.y + (offsets[node.id]?.y || 0), })); return { nodes, byId: new Map(nodes.map((node) => [node.id, node])) }; }, [baseGraph, offsets]); const selected = graphSnapshot?.nodes.find((node) => node.id === selectedId); const activeId = hoveredId || selectedId; const inbound = graphSnapshot?.edges.filter((edge) => edge.target === selectedId) || []; const outbound = graphSnapshot?.edges.filter((edge) => edge.source === selectedId) || []; const neighbors = useMemo(() => { const result = new Set(); graphSnapshot?.edges.forEach((edge) => { if (edge.source === activeId) result.add(edge.target); if (edge.target === activeId) result.add(edge.source); }); return result; }, [activeId, graphSnapshot]); const reciprocal = useMemo( () => reciprocalEdgeKeys(graphSnapshot?.edges || []), [graphSnapshot], ); const labelIds = useMemo(() => { const limit = Math.min( 10, Math.max(5, Math.ceil(Math.sqrt(graph.nodes.length) * 1.5)), ); return new Set( [...graph.nodes] .sort( (left, right) => Number(right.virtual) - Number(left.virtual) || right.degree - left.degree || left.id.localeCompare(right.id), ) .slice(0, limit) .map((node) => node.id), ); }, [graph.nodes]); const openFile = async (node: PositionedGraphNode) => { if (!node.indexed || node.virtual) return; const existing = tabs.some( (tab) => tab.type === "markdown" && tab.path === node.path, ); const id = openMarkdown(node.path); if (existing) return; try { const file = await readWorkspaceFile(node.path); hydrateMarkdown(id, file.content, file.stat.mtime); } catch (openError) { failMarkdown( id, openError instanceof Error ? openError.message : t("fileReadFailed"), ); } }; const startDrag = (event: ReactPointerEvent, nodeId: string) => { if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); dragSession.current = { pointerId: event.pointerId, nodeId, start: pointerPosition(event, zoom), base: offsets[nodeId] || { x: 0, y: 0 }, }; didDrag.current = false; setDraggingId(nodeId); event.currentTarget.setPointerCapture(event.pointerId); }; const moveDrag = (event: ReactPointerEvent) => { const drag = dragSession.current; if (!drag || drag.pointerId !== event.pointerId) return; const pointer = pointerPosition(event, zoom); const dx = pointer.x - drag.start.x; const dy = pointer.y - drag.start.y; if (Math.hypot(dx, dy) > 2) didDrag.current = true; setOffsets((current) => ({ ...current, [drag.nodeId]: { x: drag.base.x + dx, y: drag.base.y + dy }, })); }; const endDrag = (event: ReactPointerEvent) => { if (dragSession.current?.pointerId !== event.pointerId) return; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); dragSession.current = undefined; setDraggingId(""); }; if (loading && !snapshot) return (
{t("memoryGraphLoading")}
); if (error && !snapshot) return (
{t("memoryGraphLoadFailed")}
); return (
{t("memoryGraph")} · {root} {t("memoryGraphCounts", { nodes: String(graphSnapshot?.nodes.length || 0), edges: String(graphSnapshot?.edges.length || 0), })}
{!graph.nodes.length ? (
{t("memoryGraphEmpty")}
) : (
{t("memoryGraph")} {graphSnapshot?.edges.map((edge) => { const related = edge.source === activeId || edge.target === activeId; return ( {edge.source} → {edge.target} {edge.target_anchor ? `#${edge.target_anchor}` : ""} ); })} {graph.nodes.map((node) => { const active = node.id === activeId; const related = neighbors.has(node.id); return ( = 5 ? styles.hub : ""} ${ active ? styles.active : "" } ${related ? styles.related : ""} ${ activeId && !active && !related ? styles.muted : "" } ${draggingId === node.id ? styles.dragging : ""}`} style={{ transform: `translate(${node.x}px, ${node.y}px)`, }} role="button" tabIndex={0} aria-label={nodeLabel(node)} onClick={(event) => { event.stopPropagation(); if (didDrag.current) { didDrag.current = false; return; } setSelectedId(node.id); }} onDoubleClick={() => void openFile(node)} onPointerDown={(event) => startDrag(event, node.id)} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={endDrag} onMouseEnter={() => setHoveredId(node.id)} onMouseLeave={() => setHoveredId("")} onFocus={() => setHoveredId(node.id)} onBlur={() => setHoveredId("")} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSelectedId(node.id); } }} > {node.path} ); })} {graph.nodes .filter( (node) => labelIds.has(node.id) || node.id === activeId, ) .map((node) => ( {shortNodeLabel(node)} ))}
{t("memoryGraphIndexed")} {t("memoryGraphDirection")}
{selected && ( )}
)}
); }