/** * DiagramView.jsx — System diagrams + interactive codebase explorer. * * Tabs: * Explore — Interactive concept map (AI-generated, clearly labelled) * Architecture — Real import edges from AST + AI descriptions [verified] * Class Hierarchy— Real inheritance from AST + AI descriptions [verified] * Sequence — AI-generated call flow [speculative] * Data Flow — AI-generated data pipeline [speculative] * * Interactions: * - Click a diagram node → inline NodeDetailPanel slides in (no view switch) * - Click a diagram edge → NodeDetailPanel explains the relationship * - Hover a node → dims unrelated nodes, highlights connections */ import { useEffect, useRef, useState } from "react"; import { streamDiagram } from "../api"; import ExploreView from "./ExploreView"; import GraphDiagram from "./GraphDiagram"; import NodeDetailPanel from "./NodeDetailPanel"; // ── Diagram tab definitions ─────────────────────────────────────────────────── const EXPLORE_TAB = { id: "explore", label: "Explore", desc: "Guided concept tour", }; // Only AST-verified diagrams — Sequence and Data Flow were removed because // they were fully LLM-generated with no static analysis backing, making them // unreliable for a learning tool where accuracy matters. const DIAGRAM_TABS = [ { id: "architecture", label: "Architecture", desc: "Components & connections", }, { id: "class", label: "Class Hierarchy", desc: "Classes & relationships", }, ]; // ── Tab icons (SVG) ─────────────────────────────────────────────────────────── // Inline SVGs render crisp at every DPI — unicode glyphs (◈ ⬡ ◫) are // rasterised at screen resolution and look blurry on high-DPI displays. function TabIcon({ id }) { if (id === "explore") return ( ); if (id === "architecture") return ( ); if (id === "class") return ( ); return null; } const ALL_TABS = [EXPLORE_TAB, ...DIAGRAM_TABS]; export default function DiagramView({ repo, onAskAbout, focusFiles, onFullscreenChange }) { const [diagramType, setDiagramType] = useState( () => localStorage.getItem("ghrc_diagramType") || "explore" ); function setType(t) { setDiagramType(t); localStorage.setItem("ghrc_diagramType", t); // Clear any open detail panel when switching tabs setSelected(null); } const [loading, setLoading] = useState(false); const [loadStage, setLoadStage] = useState(null); // { stage, progress, message } const [error, setError] = useState(null); const [diagramData, setDiagramData] = useState(null); const [cache, setCache] = useState({}); // Per-type retry counter — using a single shared counter caused switching tabs // while retryKey > 0 to bypass the cache for the OTHER diagram type too. const [retryKeys, setRetryKeys] = useState({}); // { "architecture": 1, "class": 0, ... } const [fullscreen, setFullscreen] = useState(false); // ── Inline detail panel state ───────────────────────────────────────────── // subject = { kind, label, type, file, description, items, autoQuestion? } const [selected, setSelected] = useState(null); // Ref passed into ExploreView so we can call its force-reload from our header const exploreRegenRef = useRef(null); const isExplore = diagramType === "explore"; const selectedTypeDef = ALL_TABS.find(t => t.id === diagramType); // localStorage key for a diagram — used to persist across page refreshes. function diagLsKey(r, type) { return `ghrc_diagram_${r.replace(/\//g, "_")}_${type}`; } // ── Fetch diagram ───────────────────────────────────────────────────────── useEffect(() => { if (!repo || isExplore) return; const isForced = !!retryKeys[diagramType]; // 1. In-memory cache: survives tab switches. if (!isForced && cache[diagramType]) { setDiagramData(cache[diagramType]); setError(null); return; } // 2. localStorage cache: survives page refreshes. if (!isForced) { try { const stored = localStorage.getItem(diagLsKey(repo, diagramType)); if (stored) { const parsed = JSON.parse(stored); setCache(prev => ({ ...prev, [diagramType]: parsed })); setDiagramData(parsed); setError(null); return; } } catch { /* corrupt — fall through to fetch */ } } setLoading(true); setLoadStage(null); setError(null); setDiagramData(null); // force=true when retryKeys[diagramType] > 0 (user hit Regenerate) so the // backend bypasses its disk cache and actually produces a fresh diagram. const cancel = streamDiagram(repo, diagramType, { force: isForced, onProgress: (ev) => setLoadStage(ev), onDone: ({ diagram, type }) => { setLoading(false); setLoadStage(null); setCache(prev => ({ ...prev, [diagramType]: diagram })); setDiagramData(diagram); setRetryKeys(prev => ({ ...prev, [diagramType]: 0 })); try { localStorage.setItem(diagLsKey(repo, diagramType), JSON.stringify(diagram)); } catch {} }, onError: (msg) => { setLoading(false); setLoadStage(null); setError(msg); }, }); return cancel; }, [repo, diagramType, retryKeys[diagramType], isExplore]); // Reset on repo change — always land on Explore so the concept map is the // first thing seen when switching repos. useEffect(() => { setCache({}); setDiagramData(null); setError(null); setRetryKeys({}); setSelected(null); setDiagramType("explore"); localStorage.setItem("ghrc_diagramType", "explore"); }, [repo]); // Escape exits fullscreen useEffect(() => { if (!fullscreen) return; function onKey(e) { if (e.key === "Escape") setFullscreen(false); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [fullscreen]); // Notify parent so it can collapse the sidebar while fullscreen is on. // The sidebar sits under the z:300 overlay anyway, but collapsing it also // reclaims the grid column behind the overlay — less reflow on exit. useEffect(() => { onFullscreenChange?.(fullscreen); }, [fullscreen, onFullscreenChange]); function handleRegenerate() { try { localStorage.removeItem(diagLsKey(repo, diagramType)); } catch {} setCache(prev => { const n = {...prev}; delete n[diagramType]; return n; }); setDiagramData(null); setError(null); setSelected(null); setRetryKeys(prev => ({ ...prev, [diagramType]: (prev[diagramType] || 0) + 1 })); } // When user clicks "Open in full chat →" from the detail panel function handleOpenInChat(question) { setSelected(null); onAskAbout?.(question); } return (