import { useState, useEffect, useMemo, useCallback } from "react"; import { ChevronRight, ChevronDown, Search, X } from "lucide-react"; import type { AstNode } from "@/lib/api"; // ── Node type → CSS class mapping ───────────────────────────────────── function getNodeClass(type: string): string { const t = type.toLowerCase(); if (t.includes("keyword") || t.includes("statement") || t.includes("clause")) return "ast-keyword"; if (t.includes("literal") || t.includes("quoted") || t.includes("string")) return "ast-literal"; if (t.includes("numeric") || t.includes("integer") || t.includes("float")) return "ast-numeric"; if (t.includes("comment")) return "ast-comment"; if (t.includes("function") || t.includes("func")) return "ast-function"; if (t.includes("operator") || t.includes("comparison") || t.includes("arithmetic")) return "ast-operator"; if (t.includes("type") || t.includes("datatype")) return "ast-type"; if (t.includes("whitespace") || t.includes("newline") || t.includes("indent")) return "ast-whitespace"; return "ast-default"; } function getNodeBadgeStyle(type: string): React.CSSProperties { const t = type.toLowerCase(); if (t.includes("keyword") || t.includes("statement") || t.includes("clause")) return { background: "oklch(0.72 0.18 270 / 0.15)", color: "oklch(0.72 0.18 270)", border: "1px solid oklch(0.72 0.18 270 / 0.3)" }; if (t.includes("literal") || t.includes("quoted") || t.includes("string")) return { background: "oklch(0.75 0.16 145 / 0.15)", color: "oklch(0.75 0.16 145)", border: "1px solid oklch(0.75 0.16 145 / 0.3)" }; if (t.includes("numeric") || t.includes("integer") || t.includes("float")) return { background: "oklch(0.78 0.18 55 / 0.15)", color: "oklch(0.78 0.18 55)", border: "1px solid oklch(0.78 0.18 55 / 0.3)" }; if (t.includes("comment")) return { background: "oklch(0.50 0.010 264 / 0.15)", color: "oklch(0.55 0.010 264)", border: "1px solid oklch(0.50 0.010 264 / 0.3)" }; if (t.includes("function") || t.includes("func")) return { background: "oklch(0.78 0.16 210 / 0.15)", color: "oklch(0.78 0.16 210)", border: "1px solid oklch(0.78 0.16 210 / 0.3)" }; if (t.includes("operator") || t.includes("comparison")) return { background: "oklch(0.82 0.010 264 / 0.10)", color: "oklch(0.82 0.010 264)", border: "1px solid oklch(0.82 0.010 264 / 0.3)" }; if (t.includes("whitespace") || t.includes("newline")) return { background: "oklch(0.20 0.008 264 / 0.5)", color: "oklch(0.38 0.008 264)", border: "1px solid oklch(0.25 0.008 264 / 0.3)" }; return { background: "oklch(0.20 0.010 264 / 0.5)", color: "oklch(0.72 0.010 264)", border: "1px solid oklch(0.28 0.010 264 / 0.3)" }; } // ── Search helpers ───────────────────────────────────────────────────── function collectMatchIds(node: AstNode, query: string, matchIds: Set, ancestorIds: Set, path: string[]): boolean { const q = query.toLowerCase(); const matches = node.type.toLowerCase().includes(q) || node.name.toLowerCase().includes(q) || (node.raw?.toLowerCase().includes(q) ?? false); let childMatched = false; for (const child of node.children) { if (collectMatchIds(child, query, matchIds, ancestorIds, [...path, node.id])) { childMatched = true; } } if (matches) { matchIds.add(node.id); path.forEach((id) => ancestorIds.add(id)); } return matches || childMatched; } function highlightText(text: string, query: string): React.ReactNode { if (!query) return text; const idx = text.toLowerCase().indexOf(query.toLowerCase()); if (idx === -1) return text; return ( <> {text.slice(0, idx)} {text.slice(idx, idx + query.length)} {text.slice(idx + query.length)} ); } // ── Single tree node ─────────────────────────────────────────────────── interface NodeProps { node: AstNode; depth: number; query: string; matchIds: Set; ancestorIds: Set; expandedIds: Set; onToggle: (id: string) => void; onJumpToLine?: (line: number) => void; } function TreeNode({ node, depth, query, matchIds, ancestorIds, expandedIds, onToggle, onJumpToLine }: NodeProps) { const isMatch = matchIds.has(node.id); const isAncestor = ancestorIds.has(node.id); const isExpanded = expandedIds.has(node.id); const hasChildren = node.children.length > 0; // Skip pure whitespace nodes when no search const isWhitespace = node.type.toLowerCase().includes("whitespace") || node.type.toLowerCase().includes("newline"); if (isWhitespace && !query && !node.raw?.trim()) return null; const indent = depth * 16; const nodeClass = getNodeClass(node.type); const badgeStyle = getNodeBadgeStyle(node.type); return (
{ if (hasChildren) onToggle(node.id); if (node.start_line && onJumpToLine) onJumpToLine(node.start_line); }} > {/* Expand/collapse chevron */} {hasChildren ? ( isExpanded ? : ) : ( )} {/* Node type badge */} {highlightText(node.type, query)} {/* Raw value for leaf nodes */} {node.is_leaf && node.raw && node.raw.trim() && ( {highlightText(JSON.stringify(node.raw), query)} )} {/* Position info */} {node.start_line && ( L{node.start_line}:{node.start_pos} )}
{/* Children */} {hasChildren && isExpanded && (
{node.children.map((child) => ( ))}
)}
); } // ── Main component ───────────────────────────────────────────────────── interface AstTreeViewProps { tree: AstNode | null; tokenCount?: number; depth?: number; onJumpToLine?: (line: number) => void; } export function AstTreeView({ tree, tokenCount, depth, onJumpToLine }: AstTreeViewProps) { const [query, setQuery] = useState(""); const [expandedIds, setExpandedIds] = useState>(new Set()); // Auto-expand root on first load useEffect(() => { if (tree) { setExpandedIds(new Set([tree.id, ...tree.children.map((c) => c.id)])); } }, [tree]); const { matchIds, ancestorIds } = useMemo(() => { if (!tree || !query.trim()) return { matchIds: new Set(), ancestorIds: new Set() }; const matchIds = new Set(); const ancestorIds = new Set(); collectMatchIds(tree, query.trim(), matchIds, ancestorIds, []); return { matchIds, ancestorIds }; }, [tree, query]); // Auto-expand ancestors when searching useEffect(() => { if (query.trim() && ancestorIds.size > 0) { setExpandedIds((prev) => { const next = new Set(prev); ancestorIds.forEach((id) => next.add(id)); matchIds.forEach((id) => next.add(id)); return next; }); } }, [ancestorIds, matchIds, query]); const handleToggle = useCallback((id: string) => { setExpandedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const expandAll = useCallback(() => { if (!tree) return; const ids = new Set(); const collect = (n: AstNode) => { ids.add(n.id); n.children.forEach(collect); }; collect(tree); setExpandedIds(ids); }, [tree]); const collapseAll = useCallback(() => { if (!tree) return; setExpandedIds(new Set([tree.id])); }, [tree]); if (!tree) { return (
⟨/⟩

Run analysis to view the AST

); } return (
{/* Toolbar */}
setQuery(e.target.value)} placeholder="Filter by node type…" className="h-7 pl-7 pr-7 text-xs w-full rounded" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)", fontFamily: "var(--font-mono)", outline: "none", paddingLeft: "28px", paddingRight: "28px" }} /> {query && ( )}
{/* Stats */}
{tokenCount} tokens depth {depth} {query && matchIds.size > 0 && ( {matchIds.size} match{matchIds.size !== 1 ? "es" : ""} )} {query && matchIds.size === 0 && ( No matches )}
{/* Tree */}
); }