/** * 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 (
{/* ── Header ── */}
{!fullscreen && ( {isExplore ? `Explore — ${repo}` : `System Diagram — ${repo}`} )} {/* Fullscreen breadcrumb — the chat-header (which normally carries the repo context) is hidden in fullscreen, so we surface the same signal inline: a backend dot + owner/repo slug + current view. */} {fullscreen && (
{(() => { const [owner, name] = repo.split("/"); return (<> {owner}/ {name} ); })()} {selectedTypeDef?.label || "Explore"}
)} {/* Right-side controls — action buttons then fullscreen on the far right */}
{isExplore ? ( ) : diagramData && ( <> )}
{/* ── Focus Files banner ── */} {!fullscreen && focusFiles?.length > 0 && (
Focused on {focusFiles.length} file{focusFiles.length !== 1 ? "s" : ""} from your last answer: {focusFiles.slice(0, 3).join(", ")} {focusFiles.length > 3 ? ` +${focusFiles.length - 3} more` : ""}
)} {/* ── Tab selector ── */} {!fullscreen && (
{/* ── Explore — hero tab, visually distinct from technical diagrams ── */} {/* Vertical divider between Explore and technical tabs */}
{/* ── Technical diagram tabs ── */} {DIAGRAM_TABS.map(t => ( ))}
)} {/* ── Canvas area (diagram on top, detail tray below) ── */}
{ // Cursor-glow primitive — fed via CSS vars, painted on the compositor. const r = e.currentTarget.getBoundingClientRect(); e.currentTarget.style.setProperty("--mx", `${e.clientX - r.left}px`); e.currentTarget.style.setProperty("--my", `${e.clientY - r.top}px`); }} > {/* Keyed wrapper — replays .view-switch-in when the diagram type changes, so Explore/Architecture/Class tab switches share the app's motion language. */}
{isExplore ? ( ) : ( <> {loading && (
{loadStage?.message || `Generating ${selectedTypeDef?.label.toLowerCase()} diagram…`}
{loadStage && (
{Math.round((loadStage.progress || 0) * 100)}%
)}
)} {error && (
{error}
)} {diagramData && ( )} )}
{/* Bottom tray detail panel */} {selected && ( setSelected(null)} onOpenInChat={handleOpenInChat} /> )}
); }