From d2c43ecd1864a4ec8d739109fe048039e0cd8c12 Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Fri, 29 May 2026 07:16:39 +0530 Subject: [PATCH 1/2] feat(visualizer): add zero-latency static analysis suite - Implement circular dependency detection on adjacency list - Add percentile-based file complexity heatmap to graph nodes - Introduce orphan file detection for dead code identification - Update node state interfaces and UI legends --- app/analyze/page.tsx | 3 +- components/ArchitectureMap.tsx | 1259 +++++++++++++++++++++++++++----- 2 files changed, 1071 insertions(+), 191 deletions(-) diff --git a/app/analyze/page.tsx b/app/analyze/page.tsx index a1681ae..d28383c 100644 --- a/app/analyze/page.tsx +++ b/app/analyze/page.tsx @@ -768,6 +768,7 @@ const AnalyzeContent = memo(() => { ) : (
@@ -886,7 +887,7 @@ const AnalyzeContent = memo(() => { exit={{ x: 100, opacity: 0 }} onClick={() => setIsChatOpen(true)} aria-label="Open chat assistant" - className="absolute right-0 top-1/2 -translate-y-1/2 z-40 flex items-center gap-3 bg-[#141414]/90 backdrop-blur-md border border-white/10 border-r-0 px-4 py-4 rounded-l-2xl shadow-[-10px_0_30px_rgba(0,0,0,0.5)] hover:bg-[#1a1a1a] hover:pr-6 transition-all group" + className="absolute right-0 bottom-8 z-40 flex items-center gap-3 bg-[#141414]/90 backdrop-blur-md border border-white/10 border-r-0 px-4 py-4 rounded-l-2xl shadow-[-10px_0_30px_rgba(0,0,0,0.5)] hover:bg-[#1a1a1a] hover:pr-6 transition-all group" >
diff --git a/components/ArchitectureMap.tsx b/components/ArchitectureMap.tsx index 3292548..028d5ff 100644 --- a/components/ArchitectureMap.tsx +++ b/components/ArchitectureMap.tsx @@ -29,9 +29,92 @@ import { forceX, forceY, } from "d3-force"; +import { motion, AnimatePresence } from "framer-motion"; +import { + AlertTriangle, + CheckCircle2, + Ghost, + X, + RefreshCw, + Flame, + ChevronRight, +} from "lucide-react"; + +// ============================================================================ +// ALGORITHMS +// ============================================================================ + +function detectCircularDependencies(graph: Record): { + cycleNodes: Set; + cycleCount: number; +} { + const visited = new Set(); + const inStack = new Set(); + const cycleNodes = new Set(); + let cycleCount = 0; + + const dfs = (node: string, path: string[]) => { + if (inStack.has(node)) { + const cycleStart = path.indexOf(node); + if (cycleStart !== -1) { + cycleCount++; + for (let i = cycleStart; i < path.length; i++) cycleNodes.add(path[i]); + cycleNodes.add(node); + } + return; + } + if (visited.has(node)) return; + visited.add(node); + inStack.add(node); + path.push(node); + for (const dep of graph[node] || []) dfs(dep, path); + path.pop(); + inStack.delete(node); + }; + + for (const node of Object.keys(graph)) { + if (!visited.has(node)) dfs(node, []); + } + + return { cycleNodes, cycleCount }; +} + +function computeHeatmapColors( + fileMetrics: { path: string; size: number }[], +): Map { + const map = new Map(); + if (!fileMetrics || fileMetrics.length === 0) return map; + const sizes = fileMetrics.map((f) => f.size).sort((a, b) => a - b); + const p66 = sizes[Math.floor(sizes.length * 0.66)] ?? 0; + const p90 = sizes[Math.floor(sizes.length * 0.9)] ?? 0; + for (const f of fileMetrics) { + if (f.size >= p90) map.set(f.path, "red"); + else if (f.size >= p66) map.set(f.path, "yellow"); + else map.set(f.path, "green"); + } + return map; +} + +function detectOrphans(graph: Record): string[] { + const hasOutbound = new Set( + Object.keys(graph).filter((k) => (graph[k]?.length ?? 0) > 0), + ); + const hasInbound = new Set(); + for (const deps of Object.values(graph)) { + for (const dep of deps) hasInbound.add(dep); + } + return Object.keys(graph).filter( + (f) => !hasOutbound.has(f) && !hasInbound.has(f), + ); +} // ============================================================================ -// GLASS NODE COMPONENT +// ACTIVE MODE TYPE — single source of truth, replaces ad-hoc if/else chain +// ============================================================================ +type ActiveMode = "blast" | "circular" | "orphan" | null; + +// ============================================================================ +// GLASS NODE // ============================================================================ interface GlassNodeData { @@ -40,28 +123,79 @@ interface GlassNodeData { isEntry?: boolean; fullPath: string; label: string; + isCircular?: boolean; + heatmap?: "green" | "yellow" | "red"; + isOrphan?: boolean; + isOrphanHighlighted?: boolean; } +const HEATMAP_STYLES: Record< + "green" | "yellow" | "red", + { border: string; bg: string; shadow: string; text: string } +> = { + green: { + border: "border-emerald-500/40", + bg: "bg-emerald-500/5", + shadow: "shadow-[0_0_12px_rgba(16,185,129,0.1)]", + text: "text-emerald-200", + }, + yellow: { + border: "border-amber-500/40", + bg: "bg-amber-500/5", + shadow: "shadow-[0_0_12px_rgba(245,158,11,0.1)]", + text: "text-amber-200", + }, + red: { + border: "border-red-500/40", + bg: "bg-red-500/5", + shadow: "shadow-[0_0_12px_rgba(239,68,68,0.1)]", + text: "text-red-200", + }, +}; + const GlassNode = ({ data }: { data: GlassNodeData }) => { - const isBlastRadius = data.isBlastRadius; - const isDimmed = data.isDimmed; + const { isBlastRadius, isDimmed, isCircular, heatmap, isOrphanHighlighted } = + data; + + let containerClass = + "px-4 py-2 shadow-xl rounded-xl backdrop-blur-md min-w-[150px] transition-all duration-300 cursor-pointer"; + let textClass = "text-sm font-mono truncate max-w-[200px] transition-colors"; + let handleColor = "!bg-slate-500"; + + if (isBlastRadius) { + containerClass += + " bg-red-500/10 border border-red-500/50 shadow-[0_0_20px_rgba(239,68,68,0.2)]"; + textClass += " text-red-200"; + handleColor = "!bg-red-500"; + } else if (isDimmed) { + containerClass += " bg-[#141414]/40 border border-white/5 opacity-25"; + textClass += " text-slate-600"; + } else if (isOrphanHighlighted) { + containerClass += + " bg-violet-500/10 border border-violet-400/50 shadow-[0_0_18px_rgba(139,92,246,0.25)]"; + textClass += " text-violet-200"; + handleColor = "!bg-violet-400"; + } else if (isCircular) { + containerClass += + " bg-orange-500/10 border border-orange-500/50 shadow-[0_0_18px_rgba(249,115,22,0.2)]"; + textClass += " text-orange-200"; + handleColor = "!bg-orange-500"; + } else if (heatmap && HEATMAP_STYLES[heatmap]) { + const s = HEATMAP_STYLES[heatmap]; + containerClass += ` ${s.bg} border ${s.border} ${s.shadow}`; + textClass += ` ${s.text}`; + } else { + containerClass += + " bg-[#141414]/90 border border-white/10 hover:border-slate-500"; + textClass += " text-slate-200"; + } return ( -
+
{data.isEntry && ( @@ -69,25 +203,24 @@ const GlassNode = ({ data }: { data: GlassNodeData }) => { Entry Point
)} -
+ {data.isOrphan && !isBlastRadius && ( +
+ Orphan +
+ )} + {isCircular && !isBlastRadius && ( +
+ Circular +
+ )} +
{data.label}
); @@ -96,7 +229,7 @@ const GlassNode = ({ data }: { data: GlassNodeData }) => { const nodeTypes = { glass: GlassNode }; // ============================================================================ -// D3-FORCE LAYOUT +// D3-FORCE LAYOUT (unchanged from original) // ============================================================================ interface ForceNode extends Node { @@ -108,7 +241,7 @@ interface ForceNode extends Node { fy?: number | null; } -interface ForceLink { +interface ForceLinkType { source: string | ForceNode; target: string | ForceNode; } @@ -141,7 +274,6 @@ function getForceLayoutedElements( const forceNodes: ForceNode[] = nodes.map((node, i) => { const isHub = hubNodes.has(node.id); const isEntry = entryPoints.includes(node.id); - let x, y; if (isHub) { const angle = (i / nodes.length) * 2 * Math.PI; @@ -156,33 +288,30 @@ function getForceLayoutedElements( x = centerX + Math.cos(angle) * 150; y = centerY + Math.sin(angle) * 150; } - return { ...node, x, y }; }); - const forceLinks: ForceLink[] = edges.map((edge) => ({ + const forceLinks: ForceLinkType[] = edges.map((edge) => ({ source: edge.source, target: edge.target, })); - // --- PRINCIPAL UPGRADE: STOP THE TIMER INSTANTLY --- const simulation = forceSimulation(forceNodes) .stop() .force( "link", - forceLink(forceLinks) - .id((d: ForceNode) => d.id) - .distance((d: ForceLink) => { - const sourceDegree = nodeDegree.get((d.source as ForceNode).id) || 1; - const targetDegree = nodeDegree.get((d.target as ForceNode).id) || 1; - const avgDegree = (sourceDegree + targetDegree) / 2; - return avgDegree > maxDegree * 0.5 ? 80 : 120; + forceLink(forceLinks) + .id((d) => d.id) + .distance((d) => { + const s = nodeDegree.get((d.source as ForceNode).id) || 1; + const t = nodeDegree.get((d.target as ForceNode).id) || 1; + return (s + t) / 2 > maxDegree * 0.5 ? 80 : 120; }) .strength(1.2), ) .force( "charge", - forceManyBody().strength((d: ForceNode) => { + forceManyBody().strength((d) => { const degree = nodeDegree.get(d.id) || 1; return degree > maxDegree * 0.5 ? -600 : -300; }), @@ -191,7 +320,7 @@ function getForceLayoutedElements( .force( "collision", forceCollide() - .radius((d: ForceNode) => { + .radius((d) => { const degree = nodeDegree.get(d.id) || 1; return Math.max(80, Math.min(120, 60 + degree * 3)); }) @@ -199,23 +328,21 @@ function getForceLayoutedElements( ) .force( "x", - forceX(centerX).strength((d: ForceNode) => + forceX(centerX).strength((d) => (nodeDegree.get(d.id) || 1) > maxDegree * 0.5 ? 0.3 : 0.1, ), ) .force( "y", - forceY(centerY).strength((d: ForceNode) => + forceY(centerY).strength((d) => (nodeDegree.get(d.id) || 1) > maxDegree * 0.5 ? 0.3 : 0.1, ), ) .alphaDecay(0.01) .velocityDecay(0.4); - const numIterations = 500; - for (let i = 0; i < numIterations; i++) { + for (let i = 0; i < 500; i++) { simulation.tick(); - forceNodes.forEach((node) => { const margin = 100; node.x = Math.max(margin, Math.min(width - margin, node.x || centerX)); @@ -223,17 +350,579 @@ function getForceLayoutedElements( }); } - const layoutedNodes = forceNodes.map((node) => ({ - ...node, - position: { - x: node.x || 0, - y: node.y || 0, + return { + nodes: forceNodes.map((node) => ({ + ...node, + position: { x: node.x || 0, y: node.y || 0 }, + sourcePosition: Position.Bottom, + targetPosition: Position.Top, + })), + edges, + }; +} + +// ============================================================================ +// ANALYSIS SIDEBAR +// ============================================================================ + +interface SidebarProps { + // graph meta + nodeCount: number; + // feature 1 + cycleCount: number; + cycleNodes: Set; + // feature 2 + heatmapEnabled: boolean; + onHeatmapToggle: () => void; + // feature 3 + orphans: string[]; + selectedOrphan: string | null; + onOrphanSelect: (path: string | null) => void; + // shared + activeMode: ActiveMode; + onActivateMode: (mode: ActiveMode) => void; + onClearAll: () => void; +} + +function AnalysisSidebar({ + nodeCount, + cycleCount, + cycleNodes, + heatmapEnabled, + onHeatmapToggle, + orphans, + selectedOrphan, + onOrphanSelect, + activeMode, + onActivateMode, + onClearAll, +}: SidebarProps) { + const [expanded, setExpanded] = useState(true); + const [orphanListOpen, setOrphanListOpen] = useState(true); + + const hasCircular = cycleCount > 0; + const hasOrphans = orphans.length > 0; + + // Icon rail icons for collapsed state + const RAIL = [ + { + key: "circular" as const, + icon: , + color: hasCircular ? "text-orange-400" : "text-emerald-400", + active: activeMode === "circular", + dot: hasCircular, + dotColor: "bg-orange-500", }, - sourcePosition: Position.Bottom, - targetPosition: Position.Top, - })); + { + key: "heatmap" as const, + icon: , + color: heatmapEnabled ? "text-amber-300" : "text-slate-400", + active: heatmapEnabled, + dot: false, + dotColor: "", + }, + { + key: "orphan" as const, + icon: , + color: hasOrphans ? "text-violet-400" : "text-slate-500", + active: activeMode === "orphan", + dot: hasOrphans, + dotColor: "bg-violet-500", + }, + ]; + + return ( + + {/* ── Collapsed icon rail ── */} + + {!expanded && ( + + {/* Expand button */} + + +
+ + {/* Rail icons */} + {RAIL.map((item) => ( +
+ + {item.dot && ( + + )} +
+ ))} + + )} + + + {/* ── Expanded sidebar ── */} + + {expanded && ( + + {/* Sidebar header */} +
+
+
+ + Analysis + +
+
+ {activeMode && ( + + )} + +
+
+ + {/* Graph meta */} +
+
+ + Nodes + + + {nodeCount} + +
+
+ + Mode + + + {activeMode === "blast" + ? "Blast Radius" + : activeMode === "circular" + ? "Circular" + : activeMode === "orphan" + ? "Orphan" + : heatmapEnabled + ? "Heatmap" + : "Default"} + +
+
+ + {/* Scrollable sections */} +
+ {/* ── SECTION 1: Blast radius hint ── */} +
+
+
+ + Blast Radius + +
+

+ Click any node on the graph to trace all upstream dependents. +

+ {activeMode === "blast" && ( + +
+
+ Active — click canvas to clear +
+ + )} +
+ + {/* ── SECTION 2: Circular Dependencies ── */} +
+ + + {/* Cycle node list — shown when active */} + + {activeMode === "circular" && cycleNodes.size > 0 && ( + +
+ {Array.from(cycleNodes) + .slice(0, 8) + .map((n) => ( +
+
+ + {n.split("/").pop()} + +
+ ))} + {cycleNodes.size > 8 && ( +

+ +{cycleNodes.size - 8} more +

+ )} +
+ + )} + +
+ + {/* ── SECTION 3: Complexity Heatmap ── */} +
+
+
+
+ + + Complexity + + {/* Toggle */} + +
+ + {/* Legend — always visible so user knows what to expect */} +
+ {( + [ + { + color: "bg-emerald-400", + label: "Small", + sub: "bottom 66%", + }, + { + color: "bg-amber-400", + label: "Medium", + sub: "66–90th pct", + }, + { + color: "bg-red-400", + label: "Large", + sub: "top 10%", + }, + ] as const + ).map(({ color, label, sub }) => ( +
+
+ + {label} + + + {sub} + +
+ ))} +
+
+
+ + {/* ── SECTION 4: Orphaned Files ── */} +
+ + + {/* Orphan file list */} + + {activeMode === "orphan" && hasOrphans && orphanListOpen && ( + +
+ {orphans.map((path) => { + const label = path.split("/").pop() || path; + const dir = path.split("/").slice(-2, -1)[0] ?? ""; + const isSelected = selectedOrphan === path; + return ( + + ); + })} +

+ No imports · no importers +

+
+
+ )} +
+
+
- return { nodes: layoutedNodes, edges }; + {/* Sidebar footer */} +
+

+ 🌀 Force Layout · {nodeCount} nodes +

+
+ + )} + + + ); } // ============================================================================ @@ -243,37 +932,59 @@ function getForceLayoutedElements( export default function ArchitectureMap({ dependencyGraph = {}, entryPoints = [], + fileMetrics = [], }: { dependencyGraph: Record; entryPoints: string[]; + fileMetrics?: { path: string; size: number }[]; }) { + // ── Core state ───────────────────────────────────────────────────────────── const [selectedNode, setSelectedNode] = useState(null); - - // --- PRINCIPAL UPGRADE: State protection --- const previousGraphRef = useRef(""); + // ── Single enum for active highlight mode ────────────────────────────────── + const [activeMode, setActiveMode] = useState(null); + + // ── Feature-specific state ───────────────────────────────────────────────── + const [heatmapEnabled, setHeatmapEnabled] = useState(false); + const [selectedOrphan, setSelectedOrphan] = useState(null); + + // ── Algorithmic computations ─────────────────────────────────────────────── + const { cycleNodes, cycleCount } = useMemo( + () => detectCircularDependencies(dependencyGraph), + [dependencyGraph], + ); + + const heatmapColors = useMemo( + () => computeHeatmapColors(fileMetrics), + [fileMetrics], + ); + + const orphans = useMemo( + () => detectOrphans(dependencyGraph), + [dependencyGraph], + ); + + // ── Reverse graph for blast radius ───────────────────────────────────────── const adjacencyList = useMemo(() => { - const reverseGraph: Record = {}; + const rev: Record = {}; Object.keys(dependencyGraph).forEach((src) => { - const deps = dependencyGraph[src]; - if (Array.isArray(deps)) { - deps.forEach((tgt) => { - if (!reverseGraph[tgt]) reverseGraph[tgt] = []; - reverseGraph[tgt].push(src); - }); - } + (dependencyGraph[src] || []).forEach((tgt) => { + if (!rev[tgt]) rev[tgt] = []; + rev[tgt].push(src); + }); }); - return reverseGraph; + return rev; }, [dependencyGraph]); + // ── Build initial nodes/edges (layout only, no feature data) ─────────────── const { initialNodes, initialEdges, graphHash } = useMemo(() => { + if (!dependencyGraph || typeof dependencyGraph !== "object") + return { initialNodes: [], initialEdges: [], graphHash: "" }; + const nodes: Node[] = []; const edges: Edge[] = []; - if (!dependencyGraph || typeof dependencyGraph !== "object") { - return { initialNodes: [], initialEdges: [], graphHash: "" }; - } - Object.keys(dependencyGraph).forEach((filePath) => { nodes.push({ id: filePath, @@ -284,50 +995,48 @@ export default function ArchitectureMap({ isEntry: entryPoints.includes(filePath), isBlastRadius: false, isDimmed: false, + isCircular: false, + heatmap: undefined, + isOrphan: false, + isOrphanHighlighted: false, }, position: { x: 0, y: 0 }, }); }); Object.keys(dependencyGraph).forEach((filePath) => { - const deps = dependencyGraph[filePath]; - if (Array.isArray(deps)) { - deps.forEach((depPath) => { - edges.push({ - id: `e-${filePath}-${depPath}`, - source: filePath, - target: depPath, - animated: true, - style: { stroke: "#475569", strokeWidth: 2, opacity: 1 }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: "#475569", - }, - }); + (dependencyGraph[filePath] || []).forEach((depPath) => { + edges.push({ + id: `e-${filePath}-${depPath}`, + source: filePath, + target: depPath, + animated: true, + style: { stroke: "#475569", strokeWidth: 2, opacity: 1 }, + markerEnd: { type: MarkerType.ArrowClosed, color: "#475569" }, }); - } + }); }); - const layoutResult = getForceLayoutedElements(nodes, edges, entryPoints); + const { nodes: layoutNodes, edges: layoutEdges } = getForceLayoutedElements( + nodes, + edges, + entryPoints, + ); - // Create a deterministic hash for dependency graph comparison const graphHash = JSON.stringify({ graphKeys: Object.keys(dependencyGraph).sort(), edgesCount: edges.length, entryPoints: entryPoints.sort(), }); - return { - initialNodes: layoutResult.nodes, - initialEdges: layoutResult.edges, - graphHash, - }; + return { initialNodes: layoutNodes, initialEdges: layoutEdges, graphHash }; + }, [dependencyGraph, entryPoints]); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - // --- PRINCIPAL UPGRADE: Deep compare guard --- + // Guard: only re-layout when the graph actually changes useEffect(() => { if (previousGraphRef.current !== graphHash) { setNodes(initialNodes); @@ -336,12 +1045,36 @@ export default function ArchitectureMap({ } }, [initialNodes, initialEdges, graphHash, setNodes, setEdges]); + // ── Apply feature overlay data (does NOT re-run layout) ─────────────────── + useEffect(() => { + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { + ...n.data, + isCircular: cycleNodes.has(n.id), + heatmap: heatmapEnabled + ? (heatmapColors.get(n.id) ?? "green") + : undefined, + isOrphan: orphans.includes(n.id), + }, + })), + ); + }, [cycleNodes, heatmapColors, heatmapEnabled, orphans, setNodes]); + + // ── Master highlight effect — driven by activeMode enum ─────────────────── useEffect(() => { - if (!selectedNode) { + // Default: clear everything + if (!activeMode && !selectedNode) { setNodes((nds) => nds.map((n) => ({ ...n, - data: { ...n.data, isBlastRadius: false, isDimmed: false }, + data: { + ...n.data, + isBlastRadius: false, + isDimmed: false, + isOrphanHighlighted: false, + }, })), ); setEdges((eds) => @@ -355,62 +1088,161 @@ export default function ArchitectureMap({ return; } - const blastRadiusNodes = new Set(); - const blastRadiusEdges = new Set(); - const queue = [selectedNode]; - blastRadiusNodes.add(selectedNode); - - while (queue.length > 0) { - const current = queue.shift()!; - const dependents = adjacencyList[current] || []; - dependents.forEach((dep) => { - const edgeId = `e-${dep}-${current}`; - blastRadiusEdges.add(edgeId); - if (!blastRadiusNodes.has(dep)) { - blastRadiusNodes.add(dep); - queue.push(dep); - } - }); - } - - setNodes((nds) => - nds.map((n) => ({ - ...n, - data: { - ...n.data, - isBlastRadius: blastRadiusNodes.has(n.id), - isDimmed: !blastRadiusNodes.has(n.id), - }, - })), - ); - - setEdges((eds) => - eds.map((e) => { - const isBlastEdge = blastRadiusEdges.has(e.id); - return { + // ORPHAN mode + if (activeMode === "orphan") { + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { + ...n.data, + isBlastRadius: false, + isDimmed: selectedOrphan ? n.id !== selectedOrphan : false, + isOrphanHighlighted: selectedOrphan + ? n.id === selectedOrphan + : false, + }, + })), + ); + setEdges((eds) => + eds.map((e) => ({ ...e, style: { - stroke: isBlastEdge ? "#ef4444" : "#475569", - strokeWidth: isBlastEdge ? 3 : 2, - opacity: isBlastEdge ? 1 : 0.2, + stroke: "#475569", + strokeWidth: 2, + opacity: selectedOrphan ? 0.1 : 0.5, + }, + animated: false, + markerEnd: { type: MarkerType.ArrowClosed, color: "#475569" }, + })), + ); + return; + } + + // CIRCULAR mode + if (activeMode === "circular") { + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { + ...n.data, + isBlastRadius: false, + isDimmed: !cycleNodes.has(n.id), + isOrphanHighlighted: false, }, - animated: isBlastEdge, - markerEnd: { - type: MarkerType.ArrowClosed, - color: isBlastEdge ? "#ef4444" : "#475569", + })), + ); + setEdges((eds) => + eds.map((e) => { + const isCycleEdge = + cycleNodes.has(e.source) && cycleNodes.has(e.target); + return { + ...e, + style: { + stroke: isCycleEdge ? "#f97316" : "#475569", + strokeWidth: isCycleEdge ? 3 : 2, + opacity: isCycleEdge ? 1 : 0.08, + }, + animated: isCycleEdge, + markerEnd: { + type: MarkerType.ArrowClosed, + color: isCycleEdge ? "#f97316" : "#475569", + }, + }; + }), + ); + return; + } + + // BLAST mode (existing logic unchanged) + if (activeMode === "blast" && selectedNode) { + const blastNodes = new Set(); + const blastEdges = new Set(); + const queue = [selectedNode]; + blastNodes.add(selectedNode); + while (queue.length > 0) { + const cur = queue.shift()!; + (adjacencyList[cur] || []).forEach((dep) => { + blastEdges.add(`e-${dep}-${cur}`); + if (!blastNodes.has(dep)) { + blastNodes.add(dep); + queue.push(dep); + } + }); + } + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { + ...n.data, + isBlastRadius: blastNodes.has(n.id), + isDimmed: !blastNodes.has(n.id), + isOrphanHighlighted: false, }, - }; - }), - ); - }, [selectedNode, adjacencyList, setNodes, setEdges]); + })), + ); + setEdges((eds) => + eds.map((e) => { + const hit = blastEdges.has(e.id); + return { + ...e, + style: { + stroke: hit ? "#ef4444" : "#475569", + strokeWidth: hit ? 3 : 2, + opacity: hit ? 1 : 0.15, + }, + animated: hit, + markerEnd: { + type: MarkerType.ArrowClosed, + color: hit ? "#ef4444" : "#475569", + }, + }; + }), + ); + } + }, [ + activeMode, + selectedNode, + cycleNodes, + selectedOrphan, + adjacencyList, + setNodes, + setEdges, + ]); - const handleNodeClick = useCallback( - (_: React.MouseEvent, node: Node) => setSelectedNode(node.id), - [], - ); + // ── Handlers ────────────────────────────────────────────────────────────── + const handleNodeClick = useCallback((_: React.MouseEvent, node: Node) => { + setSelectedOrphan(null); + setSelectedNode(node.id); + setActiveMode("blast"); + }, []); - const handlePaneClick = useCallback(() => setSelectedNode(null), []); + const handlePaneClick = useCallback(() => { + setSelectedNode(null); + setSelectedOrphan(null); + setActiveMode(null); + }, []); + const handleActivateMode = useCallback((mode: ActiveMode) => { + setActiveMode(mode); + if (mode !== "blast") setSelectedNode(null); + if (mode !== "orphan") setSelectedOrphan(null); + }, []); + + const handleOrphanSelect = useCallback((path: string | null) => { + setSelectedOrphan(path); + }, []); + + const handleClearAll = useCallback(() => { + setActiveMode(null); + setSelectedNode(null); + setSelectedOrphan(null); + }, []); + + const handleHeatmapToggle = useCallback(() => { + setHeatmapEnabled((v) => !v); + }, []); + + // ── Empty state ──────────────────────────────────────────────────────────── if (nodes.length === 0) { return (
@@ -422,53 +1254,100 @@ export default function ArchitectureMap({ } return ( -
-
-

- Interactive Architecture Map -

-
-
- Click nodes to view Blast Radius -
-
- 🌀 Compact Force Layout · {nodes.length} nodes +
+ {/* ── Graph canvas (fills all available space) ── */} +
+ {/* Top-left: minimal graph label only — analysis moved to sidebar */} +
+

+ Interactive Architecture Map +

+
+
+ Click nodes to view Blast Radius +
+ + {/* Active mode pill — floats top-center, always dismissible */} + + {activeMode && ( + + + {activeMode === "blast" + ? "Blast radius active" + : activeMode === "circular" + ? "Circular deps highlighted" + : selectedOrphan + ? `Orphan: ${selectedOrphan.split("/").pop()}` + : "Orphan mode — select a file"} + + + + )} + + + + + +
- - - - + {/* ── Analysis sidebar (right, collapsible) ── */} +
); } From 5f61b5afd6a393978e037358895d564d48a47765 Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Fri, 29 May 2026 08:47:12 +0530 Subject: [PATCH 2/2] refactor(analyze): decompose page.tsx into modular hooks and UI components - Extract state management, LLM streaming, and graph traversal into custom hooks - Modularize UI into dedicated layout components (Visualizer, Doctor, Risk Radar) - Isolate static configurations and types into constants.ts - Strip page.tsx down to act strictly as a layout orchestrator --- app/analyze/page.tsx | 1011 +++---------------- components/ArchitectureMap.tsx | 1 - components/analyze/AnalyzeErrorScreen.tsx | 70 ++ components/analyze/AnalyzeHeader.tsx | 146 +++ components/analyze/AnalyzeLoadingScreen.tsx | 56 + components/analyze/AnalyzeTabBar.tsx | 55 + components/analyze/ChatPanel.tsx | 103 ++ components/analyze/DoctorPanel.tsx | 73 ++ components/analyze/ExitModal.tsx | 109 ++ components/analyze/GitHubAuthModal.tsx | 52 + components/analyze/RiskRadarPanel.tsx | 45 + components/analyze/SkeletonLoader.tsx | 20 + components/analyze/VisualizerPanel.tsx | 102 ++ components/analyze/constants.tsx | 68 ++ hooks/analyze/useAiStream.ts | 128 +++ hooks/analyze/useAnalyzeData.ts | 137 +++ hooks/analyze/useAnalyzeUIState.ts | 129 +++ hooks/analyze/useFeedback.ts | 81 ++ hooks/analyze/useLoadingAnimation.ts | 62 ++ 19 files changed, 1551 insertions(+), 897 deletions(-) create mode 100644 components/analyze/AnalyzeErrorScreen.tsx create mode 100644 components/analyze/AnalyzeHeader.tsx create mode 100644 components/analyze/AnalyzeLoadingScreen.tsx create mode 100644 components/analyze/AnalyzeTabBar.tsx create mode 100644 components/analyze/ChatPanel.tsx create mode 100644 components/analyze/DoctorPanel.tsx create mode 100644 components/analyze/ExitModal.tsx create mode 100644 components/analyze/GitHubAuthModal.tsx create mode 100644 components/analyze/RiskRadarPanel.tsx create mode 100644 components/analyze/SkeletonLoader.tsx create mode 100644 components/analyze/VisualizerPanel.tsx create mode 100644 components/analyze/constants.tsx create mode 100644 hooks/analyze/useAiStream.ts create mode 100644 hooks/analyze/useAnalyzeData.ts create mode 100644 hooks/analyze/useAnalyzeUIState.ts create mode 100644 hooks/analyze/useFeedback.ts create mode 100644 hooks/analyze/useLoadingAnimation.ts diff --git a/app/analyze/page.tsx b/app/analyze/page.tsx index d28383c..dbbdf88 100644 --- a/app/analyze/page.tsx +++ b/app/analyze/page.tsx @@ -1,59 +1,39 @@ "use client"; -import { useSearchParams, useRouter } from "next/navigation"; -import useSWR from "swr"; -import { fetcher } from "@/lib/fetcher"; -import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { useEffect, useState, - Suspense, - useMemo, - useCallback, useRef, + useCallback, + Suspense, memo, } from "react"; -import { motion, AnimatePresence, useAnimationControls } from "framer-motion"; +import { useSearchParams, useRouter } from "next/navigation"; +import { AnimatePresence } from "framer-motion"; import dynamic from "next/dynamic"; -import Link from "next/link"; -import { - ArrowLeft, - FileCode, - Terminal, - Cpu, - Layers, - ThumbsUp, - ThumbsDown, - MessageSquare, - X, - LayoutGrid, - Activity, - GitPullRequest, - Target, - CheckCircle2, -} from "lucide-react"; -import { FaGithub } from "react-icons/fa"; - -// Global Components -import ExportButton from "@/components/ExportButton"; -import ShareButton from "@/components/ShareButton"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { createClient } from "@/lib/supabase-browser"; -import { RepoData } from "@/lib/types/analyze"; - -// --- Skeleton Extracted (No Re-allocation) --- -const SkeletonLoader = memo(() => ( -
-
-
-
-
-
-
-
-)); -SkeletonLoader.displayName = "SkeletonLoader"; -// --- Lazy Load ALL Tab Components --- +// ─── Hooks ──────────────────────────────────────────────────────────────────── +import { useAnalyzeData } from "@/hooks/analyze/useAnalyzeData"; +import { useAnalyzeUIState } from "@/hooks/analyze/useAnalyzeUIState"; +import { useFeedback } from "@/hooks/analyze/useFeedback"; +import { useLoadingAnimation } from "@/hooks/analyze/useLoadingAnimation"; + +// ─── Components ─────────────────────────────────────────────────────────────── +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; +import AnalyzeLoadingScreen from "@/components/analyze/AnalyzeLoadingScreen"; +import AnalyzeErrorScreen from "@/components/analyze/AnalyzeErrorScreen"; +import GitHubAuthModal from "@/components/analyze/GitHubAuthModal"; +import AnalyzeHeader from "@/components/analyze/AnalyzeHeader"; +import AnalyzeTabBar from "@/components/analyze/AnalyzeTabBar"; +import VisualizerPanel from "@/components/analyze/VisualizerPanel"; +import DoctorPanel from "@/components/analyze/DoctorPanel"; +import RiskRadarPanel from "@/components/analyze/RiskRadarPanel"; +import ChatPanel from "@/components/analyze/ChatPanel"; +import ExitModal from "@/components/analyze/ExitModal"; + +// ─── Lazy Tab Panels (unchanged from original) ──────────────────────────────── const OverviewTab = dynamic(() => import("@/components/analyze/OverviewTab"), { loading: () => , ssr: false, @@ -62,338 +42,64 @@ const PrImpactTab = dynamic(() => import("@/components/analyze/PrImpactTab"), { loading: () => , ssr: false, }); -const DebugInterface = dynamic( - () => import("@/components/debug/DebugInterface"), - { loading: () => , ssr: false }, -); -const ArchitectureMap = dynamic(() => import("@/components/ArchitectureMap"), { - loading: () => , - ssr: false, -}); -const TreemapVisualizer = dynamic( - () => import("@/components/TreemapVisualizer"), - { loading: () => , ssr: false }, -); -const DirectoryTreeVisualizer = dynamic( - () => import("@/components/DirectoryTreeVisualizer"), - { loading: () => , ssr: false }, -); -const RiskDashboard = dynamic(() => import("@/components/RiskDashboard"), { - loading: () => , - ssr: false, -}); -const RepoChat = dynamic(() => import("@/components/RepoChat"), { - loading: () => , - ssr: false, -}); -const EXPO_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]; - -// --- Tab & View Config (No Re-allocation) --- -const TAB_CONFIG = [ - { id: "overview" as const, icon: FileCode, label: "Read Docs" }, - { id: "visualizer" as const, icon: Layers, label: "Blueprint Map" }, - { id: "doctor" as const, icon: Activity, label: "Diagnostic Engine" }, - { id: "pr_impact" as const, icon: GitPullRequest, label: "PR Impact" }, - { id: "risk_radar" as const, icon: Target, label: "Risk Radar" }, -] as const; - -const MAP_VIEW_CONFIG = [ - { id: "graph" as const, label: "Dependency Flow" }, - { id: "directory" as const, label: "Folder Structure" }, - { id: "treemap" as const, label: "Codebase Weight" }, -] as const; - -const LOADING_PHRASES = [ - "Cloning repository...", - "Decrypting source tree...", - "Mapping AST nodes...", - "Tracing execution flows...", - "Calculating blast radius...", - "Evaluating test coverage...", - "Querying Groq LLM...", - "Compiling health report...", -] as const; - -// Default skeleton analysis shown while AI stream is starting -const DEFAULT_ANALYSIS = { - architecture_pattern: "Analyzing...", - what_it_does: "Waking up Groq LLM...", - execution_flow: [], - tech_stack: [], - key_modules: [], - onboarding_guide: [], - evidence_paths: [], - blast_radius: [], - health_status: { - grade: "-", - score: 0, - status: "Pending", - refactor_plan: [], - }, -} as const; - -type TabType = (typeof TAB_CONFIG)[number]["id"]; -type MapViewType = (typeof MAP_VIEW_CONFIG)[number]["id"]; - -// --- Memoized Component (Prevents Unnecessary Re-renders) --- +// ───────────────────────────────────────────────────────────────────────────── + const AnalyzeContent = memo(() => { const searchParams = useSearchParams(); const router = useRouter(); + const repoUrl = searchParams.get("url") || searchParams.get("repo"); const source = searchParams.get("source"); - // UI State - const [showGitHubAuthModal, setShowGitHubAuthModal] = useState(false); - const [isChatOpen, setIsChatOpen] = useState(false); - const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); - const [hideFeedback, setHideFeedback] = useState(false); - const [showExitModal, setShowExitModal] = useState(false); - const exitModalRef = useRef(null); - - // AI Streaming State - const [aiAnalysis, setAiAnalysis] = useState | null>( - null, - ); - const [isAiStreaming, setIsAiStreaming] = useState(false); - - // Mount Flag + // ── Mount flag (prevents SSR / sessionStorage mismatches) ────────────────── const [isMounted, setIsMounted] = useState(false); - - // Safe Synchronous Initialization (Only runs in browser) - const [activeTab, setActiveTab] = useState(() => { - if (typeof window === "undefined") return "overview"; - const saved = sessionStorage.getItem("codeautopsy_tab"); - return saved && TAB_CONFIG.some((t) => t.id === saved) - ? (saved as TabType) - : "overview"; - }); - - const [mapView, setMapView] = useState(() => { - if (typeof window === "undefined") return "graph"; - const saved = sessionStorage.getItem("codeautopsy_view"); - return saved && MAP_VIEW_CONFIG.some((v) => v.id === saved) - ? (saved as MapViewType) - : "graph"; - }); - - // Mark as mounted useEffect(() => { - const timeoutId = setTimeout(() => setIsMounted(true), 0); - return () => clearTimeout(timeoutId); + const id = setTimeout(() => setIsMounted(true), 0); + return () => clearTimeout(id); }, []); - // Sync tab/view changes to sessionStorage - useEffect(() => { - if (isMounted) { - sessionStorage.setItem("codeautopsy_tab", activeTab); - sessionStorage.setItem("codeautopsy_view", mapView); - } - }, [activeTab, mapView, isMounted]); - - // --- Local Codebase Handler (Bypasses API) --- - const [localData] = useState(() => { - if (typeof window === "undefined" || source !== "local") return null; - const stored = sessionStorage.getItem("localAnalysisResult"); - return stored ? JSON.parse(stored) : null; - }); + // ── GitHub Auth Modal ─────────────────────────────────────────────────────── + const [showGitHubAuthModal, setShowGitHubAuthModal] = useState(false); - const [localError] = useState(() => { - if (typeof window === "undefined" || source !== "local") return null; - const stored = sessionStorage.getItem("localAnalysisResult"); - return !stored - ? "Local analysis data expired or lost. Please return to home and upload again." - : null; + // ── Data ──────────────────────────────────────────────────────────────────── + const { data, loading, error, isRateLimit, isAiStreaming } = useAnalyzeData({ + repoUrl, + source, + onRequireGitHubAuth: () => setShowGitHubAuthModal(true), }); - // --- Remote API Fetching (SWR Cache) --- + // ── UI State ──────────────────────────────────────────────────────────────── const { - data: swrData, - error: swrError, - isLoading: swrLoading, - } = useSWR( - repoUrl && source !== "local" ? ["/api/analyze", repoUrl] : null, - ([url, repo]) => fetcher(url, repo), - { - revalidateOnFocus: false, - shouldRetryOnError: false, - onError: (err) => { - if (err.message === "REQUIRE_GITHUB_AUTH") setShowGitHubAuthModal(true); - }, - }, - ); - - // --- REAL-TIME AI STREAMING ENGINE --- - useEffect(() => { - if (swrData && swrData.analysis === null && !isAiStreaming && !aiAnalysis) { - setIsAiStreaming(true); - - const fetchAiStream = async () => { - try { - const res = await fetch("/api/ai", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - repoName: `${swrData.owner}/${swrData.repo}`, - description: swrData.description, - entryPoints: swrData.entryPoints, - topFiles: swrData.topFilesForGroq, - fileContents: swrData.fileContents, - blastRadiusTargets: swrData.blastRadiusTargets, - healthMetrics: swrData.healthMetrics, - }), - }); - - if (!res.body) return; - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let jsonText = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - jsonText += decoder.decode(value, { stream: true }); - - // 1. Try to parse the complete JSON first - try { - setAiAnalysis(JSON.parse(jsonText)); - } catch { - // 2. On-the-fly Partial JSON Repair for live UI rendering - try { - let fixed = jsonText.replace(/("[^"]*)$/, '"'); // Close any open string - const openBraces = (fixed.match(/\{/g) || []).length; - const closeBraces = (fixed.match(/\}/g) || []).length; - const openBrackets = (fixed.match(/\[/g) || []).length; - const closeBrackets = (fixed.match(/\]/g) || []).length; - - fixed += - "]".repeat(Math.max(0, openBrackets - closeBrackets)) + - "}".repeat(Math.max(0, openBraces - closeBraces)); - - const partial = JSON.parse(fixed); - if (partial) setAiAnalysis(partial); - } catch { - // Silently wait for the next chunk if still unparseable - } - } - } - } catch (err) { - console.error("AI Stream Failed:", err); - } finally { - setIsAiStreaming(false); - } - }; - - fetchAiStream(); - } - }, [swrData, isAiStreaming, aiAnalysis]); - - // --- Unify Data Sources with Zod Bypass + AI Stream Injection --- - const isLocal = source === "local"; - const loading = isLocal ? !localData && !localError : swrLoading; - const rawError = isLocal - ? localError - : swrError?.message === "REQUIRE_GITHUB_AUTH" - ? null - : swrError?.message; - - const data = useMemo(() => { - if (isLocal) return localData; - if (!swrData || swrError) return null; - - // Bypass strict Zod validation: the incoming AI stream is intentionally incomplete. - // Inject streaming aiAnalysis (or a safe placeholder) directly into the SWR payload. - return { - ...swrData, - analysis: - aiAnalysis ?? - (swrData.analysis === null ? DEFAULT_ANALYSIS : swrData.analysis), - }; - }, [isLocal, localData, swrData, swrError, aiAnalysis]); - - const error = rawError ?? null; - - const isRateLimit = useMemo( - () => - !!error && - (error.includes("RATE_LIMIT_REACHED") || error.includes("Daily limit")), - [error], - ); - - // Loading Animation Loop - const [loadingStep, setLoadingStep] = useState(0); - const loadingControls = useAnimationControls(); - - useEffect(() => { - if (!loading) return; - let isActive = true; - - const animate = async () => { - while (isActive) { - await loadingControls.start({ - opacity: [0, 1], - transition: { duration: 0.4, ease: "easeOut" }, - }); - await new Promise((resolve) => setTimeout(resolve, 800)); - await loadingControls.start({ - opacity: [1, 0], - transition: { duration: 0.4, ease: "easeIn" }, - }); - if (isActive) - setLoadingStep((prev) => (prev + 1) % LOADING_PHRASES.length); - } - }; - - animate(); - return () => { - isActive = false; - }; - }, [loading, loadingControls]); - - // Escape Key & Modal Focus Traps - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key !== "Escape") return; - if (showExitModal) setShowExitModal(false); - else if (showGitHubAuthModal) router.push("/"); - else if (isChatOpen) setIsChatOpen(false); - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [showExitModal, showGitHubAuthModal, isChatOpen, router]); + activeTab, + setActiveTab, + mapView, + setMapView, + isChatOpen, + setIsChatOpen, + showExitModal, + setShowExitModal, + } = useAnalyzeUIState({ + onEscapeFromAuthModal: () => router.push("/"), + showGitHubAuthModal, + isMounted, + }); - useEffect(() => { - if (!showExitModal) return; - const modal = exitModalRef.current; - if (!modal) return; + // ── Exit modal focus trap ref ─────────────────────────────────────────────── + const exitModalRef = useRef(null); - const focusable = modal.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ); - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - - const trap = (e: KeyboardEvent) => { - if (e.key !== "Tab") return; - if (e.shiftKey) { - if (document.activeElement === first) { - e.preventDefault(); - last?.focus(); - } - } else if (document.activeElement === last) { - e.preventDefault(); - first?.focus(); - } - }; + // ── Feedback ──────────────────────────────────────────────────────────────── + const { feedbackSubmitted, hideFeedback, handleFeedback } = useFeedback({ + repoName: data?.repo, + repoUrl, + onExit: () => router.back(), + onCloseExitModal: () => setShowExitModal(false), + }); - first?.focus(); - document.addEventListener("keydown", trap); - return () => document.removeEventListener("keydown", trap); - }, [showExitModal]); + // ── Loading animation ─────────────────────────────────────────────────────── + const { loadingStep, loadingControls } = useLoadingAnimation(loading); - // Handlers + // ── GitHub OAuth handler ──────────────────────────────────────────────────── const handleGitHubLogin = useCallback(async () => { const supabase = createClient(); await supabase.auth.signInWithOAuth({ @@ -405,303 +111,51 @@ const AnalyzeContent = memo(() => { }); }, [repoUrl]); - const handleFeedback = useCallback( - async (isHelpful: boolean, exitAfter = false) => { - setFeedbackSubmitted(true); - if (exitAfter) setShowExitModal(false); - else setTimeout(() => setHideFeedback(true), 2000); - - try { - const supabase = createClient(); - await supabase.from("debug_feedback").insert([ - { - debug_id: data?.repo || repoUrl || "local-upload", - is_helpful: isHelpful, - }, - ]); - } catch (err) { - console.error(err); - } - - if (exitAfter) router.back(); - }, - [data, repoUrl, router], - ); - + // ── Back navigation ───────────────────────────────────────────────────────── const handleBackNavigation = useCallback(() => { if (!feedbackSubmitted) setShowExitModal(true); else router.back(); - }, [feedbackSubmitted, router]); + }, [feedbackSubmitted, router, setShowExitModal]); - // --- RENDERING ROUTER --- + // ── Render branches ───────────────────────────────────────────────────────── if (!isMounted || loading) return ( -
-
-
-
-
- -
-
-

- Performing Autopsy -

-

- {source === "local" - ? "Local.zip Upload" - : repoUrl?.split("/").slice(-2).join("/") || "Repository"} -

-
-
-
-
-
-
-
- - {LOADING_PHRASES[loadingStep]} - -
-
-
-
+ ); if (error) - return ( -
-
-
- {isRateLimit ? ( - - ) : ( - - )} -
-

- {isRateLimit ? "Daily Limit Reached" : "Autopsy Failed"} -

-
-

- {error} -

-
-
- {isRateLimit && ( - router.push("/pricing")} - className="w-full px-6 py-3 rounded-xl bg-white text-black font-bold text-sm hover:scale-[1.02] transition-transform" - > - View Upgrade Options - - )} - router.back()} - className="w-full px-6 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-sm font-bold text-white transition-all flex items-center justify-center gap-2" - > - Go Back - -
-
-
- ); + return ; if (!data) return null; if (showGitHubAuthModal) - return ( -
-
- -
- -
-

- Private Repository -

-

- To analyze private code, you need to authenticate with GitHub. -

-
- - Connect GitHub Account - - -
-
-
- ); + return ; + + // ── Main layout ───────────────────────────────────────────────────────────── return (
- {/* HEADER */} -
-
- -
- -

- {source === "local" ? ( - "Local Codebase" - ) : ( -
- - {data.owner} - - / - {data.repo} -
- )} -

-
- - - {data.language} - -
- {/* Streaming indicator badge */} - {isAiStreaming && ( -
-
- - AI Live - -
- )} -
-
- -
- - - Dashboard - - - {!hideFeedback && ( - - {!feedbackSubmitted ? ( -
- - Helpful? - -
- - -
-
- ) : ( -
- - - Thanks! - -
- )} -
- )} -
- {source !== "local" && ( - - )} - -
-
+ handleFeedback(isHelpful)} + /> - {/* MAIN CONTENT AREA */}
- {/* TAB LIST */} -
-
- {TAB_CONFIG.map((tab) => { - const isActive = activeTab === tab.id; - return ( - - ); - })} -
-
+ - {/* TAB PANELS */} + {/* Tab panels */}
{activeTab === "overview" && ( @@ -722,284 +176,49 @@ const AnalyzeContent = memo(() => { )} - {activeTab === "visualizer" && data && ( - -
-
-

- Blueprint Map -

- - VISUAL LAYOUT - -
-
- {MAP_VIEW_CONFIG.map((view) => ( - - ))} -
-
-
- - {mapView === "graph" ? ( - data.dependencyGraph && - Object.keys(data.dependencyGraph).length > 0 ? ( - - ) : ( -
- -

- No blueprint data parsed. -

-
- ) - ) : mapView === "directory" ? ( - - ) : ( - - )} -
-
-
+ {activeTab === "visualizer" && ( + )} {activeTab === "doctor" && ( - -
- {data.mermaidDiagram ? ( - <> -
- - - -
- setIsChatOpen(true)} - className="w-full flex-shrink-0 py-4 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all flex items-center justify-center gap-3 group shadow-lg" - > - - - Discuss this diagnosis in Copilot → - - - - ) : ( -
- -

- Diagnostic core offline. -

-
- )} -
-
+ setIsChatOpen(true)} + /> )} - {activeTab === "risk_radar" && ( - - {data.coverageGaps && data.fileContents ? ( - - - - ) : ( -
- No risk data available for this codebase. -
- )} -
- )} + {activeTab === "risk_radar" && }
- {/* CHAT / COPILOT PANEL */} - - {!isChatOpen && ( - setIsChatOpen(true)} - aria-label="Open chat assistant" - className="absolute right-0 bottom-8 z-40 flex items-center gap-3 bg-[#141414]/90 backdrop-blur-md border border-white/10 border-r-0 px-4 py-4 rounded-l-2xl shadow-[-10px_0_30px_rgba(0,0,0,0.5)] hover:bg-[#1a1a1a] hover:pr-6 transition-all group" - > -
- - -
-
- Need Help? - - Ask Copilot - -
-
- )} -
- - - {isChatOpen && ( - -
-
- -

- Autopsy Copilot -

-
- -
-
- - - -
-
- )} -
+ setIsChatOpen(true)} + onClose={() => setIsChatOpen(false)} + />
- {/* EXIT MODAL */} - - {showExitModal && ( -
- router.back()} - /> - -

- Leaving so soon? -

-

- Quick check: Was this codebase analysis helpful to you? -

-
-
- - -
- -
-
-
- )} -
+ router.back()} + />
); }); + AnalyzeContent.displayName = "AnalyzeContent"; +// ───────────────────────────────────────────────────────────────────────────── + export default function AnalyzePage() { return ( +
+
+ {isRateLimit ? ( + + ) : ( + + )} +
+ +

+ {isRateLimit ? "Daily Limit Reached" : "Autopsy Failed"} +

+ +
+

+ {error} +

+
+ +
+ {isRateLimit && ( + router.push("/pricing")} + className="w-full px-6 py-3 rounded-xl bg-white text-black font-bold text-sm hover:scale-[1.02] transition-transform" + > + View Upgrade Options + + )} + router.back()} + className="w-full px-6 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-sm font-bold text-white transition-all flex items-center justify-center gap-2" + > + Go Back + +
+
+
+ ); +} diff --git a/components/analyze/AnalyzeHeader.tsx b/components/analyze/AnalyzeHeader.tsx new file mode 100644 index 0000000..cce0f99 --- /dev/null +++ b/components/analyze/AnalyzeHeader.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { motion, AnimatePresence } from "framer-motion"; +import { + ArrowLeft, + Terminal, + LayoutGrid, + ThumbsUp, + ThumbsDown, + CheckCircle2, +} from "lucide-react"; +import { FaGithub } from "react-icons/fa"; +import Link from "next/link"; +import ExportButton from "@/components/ExportButton"; +import ShareButton from "@/components/ShareButton"; +import { RepoData } from "@/lib/types/analyze"; + +interface AnalyzeHeaderProps { + data: RepoData; + source: string | null; + isAiStreaming: boolean; + feedbackSubmitted: boolean; + hideFeedback: boolean; + onBack: () => void; + onFeedback: (isHelpful: boolean) => void; +} + +export default function AnalyzeHeader({ + data, + source, + isAiStreaming, + feedbackSubmitted, + hideFeedback, + onBack, + onFeedback, +}: AnalyzeHeaderProps) { + return ( +
+ {/* ── Left: Back + Repo Identity ── */} +
+ + +
+ + +

+ {source === "local" ? ( + "Local Codebase" + ) : ( +
+ + {data.owner} + + / + {data.repo} +
+ )} +

+ +
+ + + {data.language} + +
+ + {/* AI streaming indicator */} + {isAiStreaming && ( +
+
+ + AI Live + +
+ )} +
+
+ + {/* ── Right: Actions ── */} +
+ + + Dashboard + + + {/* Inline feedback widget */} + + {!hideFeedback && ( + + {!feedbackSubmitted ? ( +
+ + Helpful? + +
+ + +
+
+ ) : ( +
+ + + Thanks! + +
+ )} +
+ )} +
+ + {source !== "local" && ( + + )} + +
+
+ ); +} diff --git a/components/analyze/AnalyzeLoadingScreen.tsx b/components/analyze/AnalyzeLoadingScreen.tsx new file mode 100644 index 0000000..df76f63 --- /dev/null +++ b/components/analyze/AnalyzeLoadingScreen.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Cpu } from "lucide-react"; +import { LOADING_PHRASES } from "@/components/analyze/constants"; +import type { LoadingControls } from "@/hooks/analyze/useLoadingAnimation"; + +interface AnalyzeLoadingScreenProps { + repoUrl: string | null; + source: string | null; + loadingStep: number; + loadingControls: LoadingControls; +} + +export default function AnalyzeLoadingScreen({ + repoUrl, + source, + loadingStep, + loadingControls, +}: AnalyzeLoadingScreenProps) { + return ( +
+
+
+
+
+ +
+
+

+ Performing Autopsy +

+

+ {source === "local" + ? "Local.zip Upload" + : repoUrl?.split("/").slice(-2).join("/") || "Repository"} +

+
+
+
+
+
+
+
+ + {LOADING_PHRASES[loadingStep]} + +
+
+
+
+ ); +} diff --git a/components/analyze/AnalyzeTabBar.tsx b/components/analyze/AnalyzeTabBar.tsx new file mode 100644 index 0000000..fc911f6 --- /dev/null +++ b/components/analyze/AnalyzeTabBar.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { motion } from "framer-motion"; +import { TAB_CONFIG, TabType } from "@/components/analyze/constants"; + +interface AnalyzeTabBarProps { + activeTab: TabType; + onTabChange: (tab: TabType) => void; +} + +export default function AnalyzeTabBar({ + activeTab, + onTabChange, +}: AnalyzeTabBarProps) { + return ( +
+
+ {TAB_CONFIG.map((tab) => { + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/components/analyze/ChatPanel.tsx b/components/analyze/ChatPanel.tsx new file mode 100644 index 0000000..a4df451 --- /dev/null +++ b/components/analyze/ChatPanel.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { motion, AnimatePresence } from "framer-motion"; +import { Terminal, MessageSquare, X } from "lucide-react"; +import dynamic from "next/dynamic"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; +import { EXPO_OUT } from "@/components/analyze/constants"; +import { RepoData } from "@/lib/types/analyze"; +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; + +const RepoChat = dynamic(() => import("@/components/RepoChat"), { + loading: () => , + ssr: false, +}); + +interface ChatPanelProps { + data: RepoData; + isOpen: boolean; + onOpen: () => void; + onClose: () => void; +} + +export default function ChatPanel({ + data, + isOpen, + onOpen, + onClose, +}: ChatPanelProps) { + return ( + <> + {/* Floating toggle button — visible only when panel is closed */} + + {!isOpen && ( + +
+ + +
+
+ Need Help? + + Ask Copilot + +
+
+ )} +
+ + {/* Slide-in panel */} + + {isOpen && ( + + {/* Panel header */} +
+
+ +

+ Autopsy Copilot +

+
+ +
+ + {/* Chat content */} +
+ + + +
+
+ )} +
+ + ); +} diff --git a/components/analyze/DoctorPanel.tsx b/components/analyze/DoctorPanel.tsx new file mode 100644 index 0000000..4a9c756 --- /dev/null +++ b/components/analyze/DoctorPanel.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Terminal, MessageSquare } from "lucide-react"; +import dynamic from "next/dynamic"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; +import { RepoData } from "@/lib/types/analyze"; +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; + +const DebugInterface = dynamic( + () => import("@/components/debug/DebugInterface"), + { loading: () => , ssr: false }, +); + +interface DoctorPanelProps { + data: RepoData; + source: string | null; + onOpenChat: () => void; +} + +export default function DoctorPanel({ + data, + source, + onOpenChat, +}: DoctorPanelProps) { + const repoUrl = + source === "local" + ? "Local.zip Codebase" + : `https://github.com/${data.owner}/${data.repo}`; + + return ( + +
+ {data.mermaidDiagram ? ( + <> +
+ + + +
+ + + + Discuss this diagnosis in Copilot → + + + + ) : ( +
+ +

+ Diagnostic core offline. +

+
+ )} +
+
+ ); +} diff --git a/components/analyze/ExitModal.tsx b/components/analyze/ExitModal.tsx new file mode 100644 index 0000000..4224e76 --- /dev/null +++ b/components/analyze/ExitModal.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect } from "react"; +import type { RefObject } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { ThumbsUp, ThumbsDown } from "lucide-react"; +import { useRouter } from "next/navigation"; + +interface ExitModalProps { + isOpen: boolean; + modalRef: RefObject; + onFeedback: (isHelpful: boolean, exitAfter: boolean) => void; + onSkip: () => void; +} + +export default function ExitModal({ + isOpen, + modalRef, + onFeedback, + onSkip, +}: ExitModalProps) { + const router = useRouter(); + + // Focus trap: keeps keyboard focus inside the modal while it is open. + useEffect(() => { + if (!isOpen) return; + const modal = modalRef.current; + if (!modal) return; + + const focusable = modal.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + const trap = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + }; + + first?.focus(); + document.addEventListener("keydown", trap); + return () => document.removeEventListener("keydown", trap); + }, [isOpen, modalRef]); + + return ( + + {isOpen && ( +
+ {/* Backdrop */} + router.back()} + /> + + {/* Modal card */} + } + initial={{ opacity: 0, scale: 0.95, y: 10 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.95, y: 10 }} + className="relative z-10 glass-card p-6 rounded-2xl border border-white/10 max-w-sm w-full shadow-2xl bg-[#0e0e0e]" + > +

+ Leaving so soon? +

+

+ Quick check: Was this codebase analysis helpful to you? +

+ +
+
+ + +
+ +
+
+
+ )} +
+ ); +} diff --git a/components/analyze/GitHubAuthModal.tsx b/components/analyze/GitHubAuthModal.tsx new file mode 100644 index 0000000..3e9ce50 --- /dev/null +++ b/components/analyze/GitHubAuthModal.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ArrowLeft } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { FaGithub } from "react-icons/fa"; + +interface GitHubAuthModalProps { + onLogin: () => Promise; +} + +export default function GitHubAuthModal({ onLogin }: GitHubAuthModalProps) { + const router = useRouter(); + + return ( +
+
+ +
+ +
+ +

+ Private Repository +

+

+ To analyze private code, you need to authenticate with GitHub. +

+ +
+ + Connect GitHub Account + + +
+
+
+ ); +} diff --git a/components/analyze/RiskRadarPanel.tsx b/components/analyze/RiskRadarPanel.tsx new file mode 100644 index 0000000..cf98844 --- /dev/null +++ b/components/analyze/RiskRadarPanel.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { motion } from "framer-motion"; +import dynamic from "next/dynamic"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; +import { RepoData } from "@/lib/types/analyze"; +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; + +const RiskDashboard = dynamic(() => import("@/components/RiskDashboard"), { + loading: () => , + ssr: false, +}); + +interface RiskRadarPanelProps { + data: RepoData; +} + +export default function RiskRadarPanel({ data }: RiskRadarPanelProps) { + return ( + + {data.coverageGaps && data.fileContents ? ( + + + + ) : ( +
+ No risk data available for this codebase. +
+ )} +
+ ); +} diff --git a/components/analyze/SkeletonLoader.tsx b/components/analyze/SkeletonLoader.tsx new file mode 100644 index 0000000..a3619e8 --- /dev/null +++ b/components/analyze/SkeletonLoader.tsx @@ -0,0 +1,20 @@ +import { memo } from "react"; + +/** + * Generic pulsing skeleton shown while a lazy-loaded tab component is + * being fetched or while the page is in its initial loading state. + */ +const SkeletonLoader = memo(() => ( +
+
+
+
+
+
+
+
+)); + +SkeletonLoader.displayName = "SkeletonLoader"; + +export default SkeletonLoader; diff --git a/components/analyze/VisualizerPanel.tsx b/components/analyze/VisualizerPanel.tsx new file mode 100644 index 0000000..20a6092 --- /dev/null +++ b/components/analyze/VisualizerPanel.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Layers } from "lucide-react"; +import dynamic from "next/dynamic"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; +import { MAP_VIEW_CONFIG, MapViewType } from "@/components/analyze/constants"; +import { RepoData } from "@/lib/types/analyze"; +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; + +const ArchitectureMap = dynamic(() => import("@/components/ArchitectureMap"), { + loading: () => , + ssr: false, +}); +const TreemapVisualizer = dynamic( + () => import("@/components/TreemapVisualizer"), + { loading: () => , ssr: false }, +); +const DirectoryTreeVisualizer = dynamic( + () => import("@/components/DirectoryTreeVisualizer"), + { loading: () => , ssr: false }, +); + +interface VisualizerPanelProps { + data: RepoData; + mapView: MapViewType; + onMapViewChange: (view: MapViewType) => void; +} + +export default function VisualizerPanel({ + data, + mapView, + onMapViewChange, +}: VisualizerPanelProps) { + return ( + + {/* Sub-view switcher */} +
+
+

+ Blueprint Map +

+ + VISUAL LAYOUT + +
+
+ {MAP_VIEW_CONFIG.map((view) => ( + + ))} +
+
+ + {/* Map canvas */} +
+ + {mapView === "graph" ? ( + data.dependencyGraph && + Object.keys(data.dependencyGraph).length > 0 ? ( + + ) : ( +
+ +

+ No blueprint data parsed. +

+
+ ) + ) : mapView === "directory" ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/components/analyze/constants.tsx b/components/analyze/constants.tsx new file mode 100644 index 0000000..4a02318 --- /dev/null +++ b/components/analyze/constants.tsx @@ -0,0 +1,68 @@ +import { + FileCode, + Layers, + Activity, + GitPullRequest, + Target, +} from "lucide-react"; + +// ─── Framer Motion ──────────────────────────────────────────────────────────── + +export const EXPO_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]; + +// ─── Tab Configuration ──────────────────────────────────────────────────────── + +export const TAB_CONFIG = [ + { id: "overview" as const, icon: FileCode, label: "Read Docs" }, + { id: "visualizer" as const, icon: Layers, label: "Blueprint Map" }, + { id: "doctor" as const, icon: Activity, label: "Diagnostic Engine" }, + { id: "pr_impact" as const, icon: GitPullRequest, label: "PR Impact" }, + { id: "risk_radar" as const, icon: Target, label: "Risk Radar" }, +] as const; + +export type TabType = (typeof TAB_CONFIG)[number]["id"]; + +// ─── Map View Configuration ─────────────────────────────────────────────────── + +export const MAP_VIEW_CONFIG = [ + { id: "graph" as const, label: "Dependency Flow" }, + { id: "directory" as const, label: "Folder Structure" }, + { id: "treemap" as const, label: "Codebase Weight" }, +] as const; + +export type MapViewType = (typeof MAP_VIEW_CONFIG)[number]["id"]; + +// ─── Loading Screen Phrases ─────────────────────────────────────────────────── + +export const LOADING_PHRASES = [ + "Cloning repository...", + "Decrypting source tree...", + "Mapping AST nodes...", + "Tracing execution flows...", + "Calculating blast radius...", + "Evaluating test coverage...", + "Querying Groq LLM...", + "Compiling health report...", +] as const; + +// ─── Default Analysis Skeleton ──────────────────────────────────────────────── +// Shown in the UI while the AI stream is starting up. +// Intentionally does NOT satisfy the full Analysis interface — the streaming +// engine progressively replaces these placeholder values. + +export const DEFAULT_ANALYSIS = { + architecture_pattern: "Analyzing...", + what_it_does: "Waking up Groq LLM...", + execution_flow: [], + tech_stack: [], + key_modules: [], + onboarding_guide: [], + evidence_paths: [], + blast_radius: [], + health_status: { + grade: "-", + score: 0, + status: "Pending", + refactor_plan: [], + }, +} as const; diff --git a/hooks/analyze/useAiStream.ts b/hooks/analyze/useAiStream.ts new file mode 100644 index 0000000..395ced5 --- /dev/null +++ b/hooks/analyze/useAiStream.ts @@ -0,0 +1,128 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { RepoData } from "@/lib/types/analyze"; + +// The subset of swrData fields that the AI endpoint consumes. +// Keeps the hook signature explicit and avoids a full RepoData dependency. +interface AiStreamInput { + owner: string; + repo: string; + description: string; + entryPoints: string[]; + topFilesForGroq?: unknown; + fileContents?: { path: string; content: string }[]; + blastRadiusTargets?: unknown; + healthMetrics?: unknown; + // analysis is null when the server deferred AI generation to the client stream + analysis: RepoData["analysis"] | null; +} + +interface UseAiStreamReturn { + aiAnalysis: Record | null; + isAiStreaming: boolean; +} + +/** + * Owns the real-time AI streaming engine. + * + * When `swrData.analysis` comes back as `null`, the server has signalled that + * the AI generation should be streamed client-side. This hook opens a POST + * stream to `/api/ai`, reads NDJSON chunks, and applies on-the-fly partial + * JSON repair so the UI can render progressive updates before the stream + * completes. + * + * The hook is intentionally idempotent: once streaming has started (or + * completed), it will not re-trigger for the same `swrData` reference. + */ +export function useAiStream( + swrData: AiStreamInput | null | undefined, +): UseAiStreamReturn { + const [aiAnalysis, setAiAnalysis] = useState | null>( + null, + ); + const [isAiStreaming, setIsAiStreaming] = useState(false); + + useEffect(() => { + // Only start the stream when: + // 1. swrData has arrived + // 2. The server explicitly deferred analysis (analysis === null) + // 3. We are not already streaming + // 4. We have not already received a completed result + if (!swrData || swrData.analysis !== null || isAiStreaming || aiAnalysis) { + return; + } + + setIsAiStreaming(true); + + const fetchAiStream = async () => { + try { + const res = await fetch("/api/ai", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repoName: `${swrData.owner}/${swrData.repo}`, + description: swrData.description, + entryPoints: swrData.entryPoints, + topFiles: swrData.topFilesForGroq, + fileContents: swrData.fileContents, + blastRadiusTargets: swrData.blastRadiusTargets, + healthMetrics: swrData.healthMetrics, + }), + }); + + if (!res.body) return; + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let jsonText = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + jsonText += decoder.decode(value, { stream: true }); + + // 1. Attempt to parse the complete accumulated JSON first. + try { + setAiAnalysis(JSON.parse(jsonText)); + } catch { + // 2. On-the-fly partial JSON repair for progressive UI rendering. + // The incoming stream is valid JSON that may be mid-write, so we + // close any open string literals and balance braces/brackets. + try { + let fixed = jsonText.replace(/("[^"]*)$/, '"'); + + const openBraces = (fixed.match(/\{/g) || []).length; + const closeBraces = (fixed.match(/\}/g) || []).length; + const openBrackets = (fixed.match(/\[/g) || []).length; + const closeBrackets = (fixed.match(/\]/g) || []).length; + + fixed += + "]".repeat(Math.max(0, openBrackets - closeBrackets)) + + "}".repeat(Math.max(0, openBraces - closeBraces)); + + const partial = JSON.parse(fixed); + if (partial) setAiAnalysis(partial); + } catch { + // Silently wait for the next chunk if the fragment is still + // unparseable after repair attempts. + } + } + } + } catch (err) { + console.error("AI Stream Failed:", err); + } finally { + setIsAiStreaming(false); + } + }; + + fetchAiStream(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [swrData]); + // Intentionally omitting `isAiStreaming` and `aiAnalysis` from deps: + // the guard conditions at the top of the effect body are sufficient, and + // including them would risk re-triggering the stream on state updates. + + return { aiAnalysis, isAiStreaming }; +} \ No newline at end of file diff --git a/hooks/analyze/useAnalyzeData.ts b/hooks/analyze/useAnalyzeData.ts new file mode 100644 index 0000000..027458b --- /dev/null +++ b/hooks/analyze/useAnalyzeData.ts @@ -0,0 +1,137 @@ +"use client"; + +import { useState, useMemo } from "react"; +import useSWR from "swr"; +import { fetcher } from "@/lib/fetcher"; +import { RepoData, Analysis } from "@/lib/types/analyze"; +import { useAiStream } from "./useAiStream"; +import { DEFAULT_ANALYSIS } from "@/components/analyze/constants"; + +interface UseAnalyzeDataOptions { + repoUrl: string | null; + source: string | null; + onRequireGitHubAuth: () => void; +} + +interface UseAnalyzeDataReturn { + /** Fully resolved data, with AI analysis injected (streaming or cached). */ + data: RepoData | null; + loading: boolean; + error: string | null; + isRateLimit: boolean; + isAiStreaming: boolean; +} + +/** + * Owns all data-fetching concerns for the analyze page. + * + * Responsibilities: + * - SWR-cached remote fetch via `/api/analyze` + * - Local-upload fallback from `sessionStorage` + * - AI stream injection via `useAiStream` + * - Unified `data`, `loading`, and `error` surface for consumers + * + * The `onRequireGitHubAuth` callback is fired instead of surfacing an error + * string when the API returns `REQUIRE_GITHUB_AUTH`, so the caller can show + * the auth modal without coupling this hook to UI state. + */ +export function useAnalyzeData({ + repoUrl, + source, + onRequireGitHubAuth, +}: UseAnalyzeDataOptions): UseAnalyzeDataReturn { + const isLocal = source === "local"; + + // ─── Local Upload Path ────────────────────────────────────────────────────── + // Read once on mount. We use plain `useState` initializers (not effects) so + // the value is available synchronously on the first render. + + const [localData] = useState(() => { + if (typeof window === "undefined" || !isLocal) return null; + const stored = sessionStorage.getItem("localAnalysisResult"); + return stored ? (JSON.parse(stored) as RepoData) : null; + }); + + const [localError] = useState(() => { + if (typeof window === "undefined" || !isLocal) return null; + const stored = sessionStorage.getItem("localAnalysisResult"); + return !stored + ? "Local analysis data expired or lost. Please return to home and upload again." + : null; + }); + + // ─── Remote Fetch Path (SWR) ──────────────────────────────────────────────── + + const { + data: swrData, + error: swrError, + isLoading: swrLoading, + } = useSWR( + repoUrl && !isLocal ? ["/api/analyze", repoUrl] : null, + ([url, repo]) => fetcher(url, repo), + { + revalidateOnFocus: false, + shouldRetryOnError: false, + onError: (err: Error) => { + if (err.message === "REQUIRE_GITHUB_AUTH") onRequireGitHubAuth(); + }, + }, + ); + + // ─── AI Stream ────────────────────────────────────────────────────────────── + // Pass swrData to the stream hook. It self-guards and will only open a + // stream when `swrData.analysis === null`. + + const { aiAnalysis, isAiStreaming } = useAiStream( + isLocal ? null : swrData, + ); + + // ─── Unified Data Surface ─────────────────────────────────────────────────── + + const data = useMemo(() => { + if (isLocal) return localData; + if (!swrData || swrError) return null; + + // The server may return `analysis: null` to signal client-side streaming. + // We inject the live aiAnalysis (or a safe placeholder skeleton) so the + // rest of the UI never needs to handle a null analysis field. + const resolvedAnalysis: Analysis = + aiAnalysis != null + ? (aiAnalysis as unknown as Analysis) + : swrData.analysis !== null + ? swrData.analysis + : (DEFAULT_ANALYSIS as unknown as Analysis); + + return { + ...swrData, + analysis: resolvedAnalysis, + } as RepoData; + }, [isLocal, localData, swrData, swrError, aiAnalysis]); + + // ─── Error Derivation ─────────────────────────────────────────────────────── + // Strip the REQUIRE_GITHUB_AUTH sentinel — that path is handled via the + // callback above and should not surface as a visible error string. + + const rawErrorMessage = + swrError?.message === "REQUIRE_GITHUB_AUTH" ? null : swrError?.message; + + const error: string | null = isLocal + ? localError + : (rawErrorMessage ?? null); + + const isRateLimit = + !!error && + (error.includes("RATE_LIMIT_REACHED") || error.includes("Daily limit")); + + // ─── Loading ───────────────────────────────────────────────────────────────── + + const loading = isLocal ? !localData && !localError : swrLoading; + + return { + data, + loading, + error, + isRateLimit, + isAiStreaming, + }; +} \ No newline at end of file diff --git a/hooks/analyze/useAnalyzeUIState.ts b/hooks/analyze/useAnalyzeUIState.ts new file mode 100644 index 0000000..df2b5e7 --- /dev/null +++ b/hooks/analyze/useAnalyzeUIState.ts @@ -0,0 +1,129 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + TAB_CONFIG, + MAP_VIEW_CONFIG, + TabType, + MapViewType, +} from "@/components/analyze/constants"; + +interface UseAnalyzeUIStateOptions { + /** + * Called when Escape is pressed and the GitHub auth modal is the active + * layer. The caller owns that modal's visibility state, so we delegate the + * navigation action rather than importing the router here. + */ + onEscapeFromAuthModal: () => void; + /** Whether the GitHub auth modal is currently visible. */ + showGitHubAuthModal: boolean; + /** Whether the mount flag has been set (prevents SSR sessionStorage reads). */ + isMounted: boolean; +} + +interface UseAnalyzeUIStateReturn { + activeTab: TabType; + setActiveTab: (tab: TabType) => void; + mapView: MapViewType; + setMapView: (view: MapViewType) => void; + isChatOpen: boolean; + setIsChatOpen: (open: boolean) => void; + showExitModal: boolean; + setShowExitModal: (open: boolean) => void; +} + +/** + * Owns all ephemeral UI state for the analyze page. + * + * Responsibilities: + * - `activeTab` and `mapView` with `sessionStorage` persistence + * - `isChatOpen` and `showExitModal` panel visibility + * - Global Escape key handler (dispatches to the correct layer) + */ +export function useAnalyzeUIState({ + onEscapeFromAuthModal, + showGitHubAuthModal, + isMounted, +}: UseAnalyzeUIStateOptions): UseAnalyzeUIStateReturn { + // ─── Tab & View State ──────────────────────────────────────────────────────── + // Safe synchronous initialization: sessionStorage is only read in the browser. + + const [activeTab, setActiveTabRaw] = useState(() => { + if (typeof window === "undefined") return "overview"; + const saved = sessionStorage.getItem("codeautopsy_tab"); + return saved && TAB_CONFIG.some((t) => t.id === saved) + ? (saved as TabType) + : "overview"; + }); + + const [mapView, setMapViewRaw] = useState(() => { + if (typeof window === "undefined") return "graph"; + const saved = sessionStorage.getItem("codeautopsy_view"); + return saved && MAP_VIEW_CONFIG.some((v) => v.id === saved) + ? (saved as MapViewType) + : "graph"; + }); + + // ─── Panel Visibility ──────────────────────────────────────────────────────── + + const [isChatOpen, setIsChatOpen] = useState(false); + const [showExitModal, setShowExitModal] = useState(false); + + // ─── sessionStorage Sync ───────────────────────────────────────────────────── + // Only write after the component has mounted to avoid SSR mismatches. + + useEffect(() => { + if (!isMounted) return; + sessionStorage.setItem("codeautopsy_tab", activeTab); + }, [activeTab, isMounted]); + + useEffect(() => { + if (!isMounted) return; + sessionStorage.setItem("codeautopsy_view", mapView); + }, [mapView, isMounted]); + + // ─── Escape Key Handler ─────────────────────────────────────────────────────── + // Layers (highest priority first): + // 1. Exit modal + // 2. GitHub auth modal → delegate navigation to caller + // 3. Chat panel + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (showExitModal) { + setShowExitModal(false); + } else if (showGitHubAuthModal) { + onEscapeFromAuthModal(); + } else if (isChatOpen) { + setIsChatOpen(false); + } + }; + + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [showExitModal, showGitHubAuthModal, isChatOpen, onEscapeFromAuthModal]); + + // ─── Stable Setters ─────────────────────────────────────────────────────────── + // Wrapped in useCallback so consumers can use them in their own dependency + // arrays without risking stale-closure issues. + + const setActiveTab = useCallback((tab: TabType) => { + setActiveTabRaw(tab); + }, []); + + const setMapView = useCallback((view: MapViewType) => { + setMapViewRaw(view); + }, []); + + return { + activeTab, + setActiveTab, + mapView, + setMapView, + isChatOpen, + setIsChatOpen, + showExitModal, + setShowExitModal, + }; +} \ No newline at end of file diff --git a/hooks/analyze/useFeedback.ts b/hooks/analyze/useFeedback.ts new file mode 100644 index 0000000..23a961b --- /dev/null +++ b/hooks/analyze/useFeedback.ts @@ -0,0 +1,81 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { createClient } from "@/lib/supabase-browser"; + +interface UseFeedbackOptions { + /** + * The repo name or identifier used as the `debug_id` in Supabase. + * Falls back to `repoUrl`, then `"local-upload"` if neither is available. + */ + repoName: string | null | undefined; + repoUrl: string | null | undefined; + /** Called after feedback is submitted when `exitAfter` is true. */ + onExit: () => void; + /** Called to close the exit modal regardless of exit intent. */ + onCloseExitModal: () => void; +} + +interface UseFeedbackReturn { + feedbackSubmitted: boolean; + hideFeedback: boolean; + /** + * @param isHelpful - Whether the user found the analysis helpful. + * @param exitAfter - If true, closes the exit modal and calls `onExit` after + * the Supabase insert completes (fire-and-forget; errors are swallowed). + */ + handleFeedback: (isHelpful: boolean, exitAfter?: boolean) => Promise; +} + +/** + * Owns the feedback UI state and the Supabase persistence side-effect. + * + * The Supabase insert is fire-and-forget: errors are logged but never surfaced + * to the user, matching the original behaviour in page.tsx. + */ +export function useFeedback({ + repoName, + repoUrl, + onExit, + onCloseExitModal, +}: UseFeedbackOptions): UseFeedbackReturn { + const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); + const [hideFeedback, setHideFeedback] = useState(false); + + const handleFeedback = useCallback( + async (isHelpful: boolean, exitAfter = false) => { + setFeedbackSubmitted(true); + + if (exitAfter) { + onCloseExitModal(); + } else { + // Auto-hide the inline feedback widget after the success animation + // completes (2 seconds), matching the original behaviour. + setTimeout(() => setHideFeedback(true), 2000); + } + + try { + const supabase = createClient(); + await supabase.from("debug_feedback").insert([ + { + debug_id: repoName ?? repoUrl ?? "local-upload", + is_helpful: isHelpful, + }, + ]); + } catch (err) { + console.error("Feedback insert failed:", err); + } + + if (exitAfter) { + onExit(); + } + }, + [repoName, repoUrl, onExit, onCloseExitModal], + ); + + return { + feedbackSubmitted, + hideFeedback, + handleFeedback, + }; +} \ No newline at end of file diff --git a/hooks/analyze/useLoadingAnimation.ts b/hooks/analyze/useLoadingAnimation.ts new file mode 100644 index 0000000..ad8dcc4 --- /dev/null +++ b/hooks/analyze/useLoadingAnimation.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useAnimationControls } from "framer-motion"; +import { LOADING_PHRASES } from "@/components/analyze/constants"; + +export type LoadingControls = ReturnType; + +interface UseLoadingAnimationReturn { + loadingStep: number; + loadingControls: LoadingControls; +} + +/** + * Drives the loading screen phrase animation. + * + * Cycles through `LOADING_PHRASES` with a fade-out / increment / fade-in + * loop for as long as `loading` is `true`. The loop is cancelled cleanly + * via the `isActive` flag when the effect tears down. + * + * @param loading - Should be `true` while data is still being fetched. + */ +export function useLoadingAnimation( + loading: boolean, +): UseLoadingAnimationReturn { + const [loadingStep, setLoadingStep] = useState(0); + const loadingControls = useAnimationControls(); + + useEffect(() => { + if (!loading) return; + + let isActive = true; + + const animate = async () => { + while (isActive) { + await loadingControls.start({ + opacity: [0, 1], + transition: { duration: 0.4, ease: "easeOut" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 800)); + + await loadingControls.start({ + opacity: [1, 0], + transition: { duration: 0.4, ease: "easeIn" }, + }); + + if (isActive) { + setLoadingStep((prev) => (prev + 1) % LOADING_PHRASES.length); + } + } + }; + + animate(); + + return () => { + isActive = false; + }; + }, [loading, loadingControls]); + + return { loadingStep, loadingControls }; +} \ No newline at end of file