UNPKG

socraticode

Version:

SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.

740 lines (680 loc) • 32.9 kB
/* SocratiCode interactive graph viewer — app logic * Loaded after Cytoscape + Dagre + cytoscape-dagre are on window. * Reads embedded graph data from window.__SOCRATICODE_DATA__. * * All DOM rendering uses createElement + textContent for XSS safety — no * innerHTML is used anywhere, so data fields with HTML-looking content * (rare but possible in symbol names) cannot escape into markup. */ (function () { "use strict"; const DATA = window.__SOCRATICODE_DATA__; if (!DATA) throw new Error("Missing __SOCRATICODE_DATA__"); // Language → colour palette (kept in sync with Mermaid generator) const LANG_COLORS = { typescript: "#3178C6", javascript: "#F7DF1E", python: "#3776AB", java: "#ED8B00", kotlin: "#7F52FF", go: "#00ADD8", rust: "#CE422B", ruby: "#CC342D", php: "#777BB4", swift: "#FA7343", c: "#A8B9CC", cpp: "#00599C", csharp: "#239120", scala: "#DC322F", dart: "#0175C2", elixir: "#4B275F", lua: "#2C2D72", shell: "#4EAA25", html: "#E34F26", css: "#1572B6", json: "#808080", }; const FALLBACK_COLOR = "#607D8B"; const colourFor = (lang) => LANG_COLORS[lang] || FALLBACK_COLOR; // Symbol-kind → colour const KIND_COLORS = { function: "#3b82f6", class: "#a855f7", method: "#06b6d4", constructor: "#0891b2", interface: "#f59e0b", trait: "#f59e0b", enum: "#ec4899", module: "#6b7280", struct: "#a855f7", variable: "#84cc16", }; // ── Tiny DOM builder (XSS-safe — all text via textContent) ─────── function h(tag, attrs, ...children) { const el = document.createElement(tag); if (attrs) { for (const [k, v] of Object.entries(attrs)) { if (v == null || v === false) continue; if (k === "class") el.className = v; else if (k === "style") el.setAttribute("style", v); else if (k.startsWith("data-")) el.setAttribute(k, String(v)); else if (k === "title") el.title = String(v); else if (k === "disabled") el.disabled = true; else el[k] = v; } } for (const c of children) { if (c == null || c === false) continue; el.appendChild(typeof c === "string" || typeof c === "number" ? document.createTextNode(String(c)) : c); } return el; } // ── Register layouts ────────────────────────────────────────────── if (window.cytoscapeDagre) window.cytoscapeDagre(cytoscape); // ── Build Cytoscape elements from data ─────────────────────────── const FILE_NODES = DATA.files.map((f) => ({ group: "nodes", data: { id: `f::${f.id}`, label: f.label, nodeType: "file", file: f.id, language: f.language, deps: f.deps, dependents: f.dependents, symbolCount: f.symbolCount || 0, }, })); const FILE_EDGES = DATA.fileEdges.map((e, i) => ({ group: "edges", data: { id: `fe${i}`, source: `f::${e.source}`, target: `f::${e.target}`, edgeType: e.type, cyclic: e.cyclic, scope: "file", }, classes: `${e.cyclic ? "cyclic" : ""} ${e.type === "dynamic-import" ? "dynamic" : ""}`.trim(), })); // Symbol-side indices — Symbol view is a FOCUS graph that builds on // demand, not a pre-rendered global graph. These lookup maps let // setSymbolSeed() compute a 2-hop neighbourhood around any symbol in // O(neighbours) time without touching Cytoscape. const SYMBOL_BY_ID = new Map(); for (const s of DATA.symbols || []) SYMBOL_BY_ID.set(s.id, s); const OUTGOING_BY_CALLER = new Map(); const INCOMING_BY_CALLEE = new Map(); for (const e of DATA.symbolEdges || []) { if (!OUTGOING_BY_CALLER.has(e.source)) OUTGOING_BY_CALLER.set(e.source, []); OUTGOING_BY_CALLER.get(e.source).push(e); if (!INCOMING_BY_CALLEE.has(e.target)) INCOMING_BY_CALLEE.set(e.target, []); INCOMING_BY_CALLEE.get(e.target).push(e); } /** Stable pastel colour derived from a file path — used as border * colour on symbol nodes so users can eyeball "which symbols live in * the same file" even when nodes are small. */ function fileBorderColor(filePath) { if (!filePath) return "#0f172a"; let h = 5381; for (let i = 0; i < filePath.length; i++) h = ((h << 5) + h + filePath.charCodeAt(i)) | 0; return `hsl(${Math.abs(h) % 360}, 55%, 45%)`; } // Hide labels below this zoom (too cluttered with 100+ nodes rendered at // full zoom-out). Selected / neighbour nodes always show their label // via explicit overrides in the stylesheet. // NOTE: both this constant and `currentZoom` MUST be declared before the // STYLE array — Cytoscape evaluates the label closures during the cy() // constructor call, which happens before any `const` below it binds. A // forward reference would hit the temporal dead zone and blank the graph. const LABEL_ZOOM_THRESHOLD = 0.55; // Tracked zoom state — a plain variable readable from the style // functions below, which run inside the cytoscape() constructor call // before `cy` itself is bound. Using `cy.zoom()` inside those closures // would hit the TDZ (`Cannot access 'cy' before initialization`) the // very first time Cytoscape styles the initial elements. let currentZoom = 1; // ── Cytoscape style ────────────────────────────────────────────── const STYLE = [ { selector: 'node[nodeType = "file"]', style: { "background-color": (ele) => colourFor(ele.data("language")), // Hide label below threshold unless the node is selected/highlighted. "label": (ele) => (currentZoom >= LABEL_ZOOM_THRESHOLD || ele.hasClass("always-label")) ? ele.data("label") : "", "font-size": 10, "color": "#1f2937", "text-valign": "bottom", "text-margin-y": 4, "text-background-color": "#fff", "text-background-opacity": 0.8, "text-background-padding": 2, "width": (ele) => 14 + Math.min(30, (ele.data("dependents") || 0) * 2), "height": (ele) => 14 + Math.min(30, (ele.data("dependents") || 0) * 2), "border-width": 1, "border-color": "#0f172a", }, }, { selector: 'node[nodeType = "symbol"]', style: { "background-color": (ele) => KIND_COLORS[ele.data("kind")] || "#64748b", "label": (ele) => (currentZoom >= LABEL_ZOOM_THRESHOLD || ele.hasClass("always-label") || ele.hasClass("seed")) ? ele.data("label") : "", "font-size": 10, "color": "#1f2937", "text-valign": "bottom", "text-margin-y": 4, "shape": "round-rectangle", "width": 44, "height": 20, // Border colour encodes the symbol's file — "light" compound-node // behaviour without actual nesting. Same file → same border. "border-width": 2, "border-color": (ele) => fileBorderColor(ele.data("file")), }, }, // The focus-graph seed — the symbol the user searched for / clicked // into. Bigger, pinned-label, distinctive ring so it's obvious which // node is the anchor. { selector: 'node[nodeType = "symbol"].seed', style: { "width": 64, "height": 28, "border-width": 4, "border-color": "#f59e0b", "font-size": 12, "font-weight": 600, "z-index": 1001, }, }, { selector: "edge", style: { "width": 1.5, "line-color": "#cbd5e1", "target-arrow-color": "#cbd5e1", "target-arrow-shape": "triangle", "curve-style": "bezier", "arrow-scale": 0.8, }, }, { selector: "edge.dynamic", style: { "line-style": "dashed" } }, { selector: "edge.cyclic", style: { "line-color": "#dc2626", "target-arrow-color": "#dc2626", "width": 2.5, "line-style": "dashed" } }, { selector: "edge.conf-multiple-candidates", style: { "line-style": "dashed", "line-color": "#a78bfa", "target-arrow-color": "#a78bfa" } }, { selector: "edge.conf-unresolved", style: { "line-style": "dotted", "line-color": "#94a3b8", "target-arrow-color": "#94a3b8" } }, { selector: ".faded", style: { "opacity": 0.15, "text-opacity": 0.1 } }, { selector: ".highlight", style: { "background-color": "#ef4444", "border-color": "#991b1b", "border-width": 2, "z-index": 999, }, }, { selector: ".highlight-flow", style: { "background-color": "#3b82f6", "border-color": "#1e40af", "border-width": 2, "z-index": 999, }, }, { selector: "edge.highlight-edge", style: { "line-color": "#ef4444", "target-arrow-color": "#ef4444", "width": 3, "z-index": 999 } }, { selector: "edge.highlight-flow-edge", style: { "line-color": "#3b82f6", "target-arrow-color": "#3b82f6", "width": 3, "z-index": 999 } }, // Plain node-click neighbourhood highlight (less aggressive than blast // radius; just surfaces a node's direct imports + dependents). { selector: ".highlight-selected", style: { "background-color": "#2563eb", "border-color": "#1e40af", "border-width": 3, "z-index": 1000 } }, { selector: ".highlight-neighbour", style: { "border-color": "#2563eb", "border-width": 2, "z-index": 998 } }, { selector: "edge.highlight-neighbour-edge", style: { "line-color": "#2563eb", "target-arrow-color": "#2563eb", "width": 2.5, "z-index": 998 } }, // Force-show label on the currently-selected node and its neighbours. { selector: ".always-label", style: { "text-opacity": 1 } }, ]; // ── Instantiate Cytoscape ──────────────────────────────────────── const cy = cytoscape({ container: document.getElementById("cy"), elements: [...FILE_NODES, ...FILE_EDGES], style: STYLE, wheelSensitivity: 0.2, minZoom: 0.1, maxZoom: 3, // Disable single-click drag: trackpad "clicks" always have some motion, // which Cytoscape's default interprets as a node grab. This is an // exploration viewer — users tap to inspect, not to rearrange. autoungrabify: true, // Disable box (rectangle) selection to avoid accidental selections. boxSelectionEnabled: false, }); // ── Layout runner ──────────────────────────────────────────────── let currentView = "files"; // Default to Dagre TB for small graphs (clean layered look) and Dagre // for large graphs too. Cose is available but not default — it's non- // deterministic and looks chaotic on dense code dep graphs. let currentLayout = "dagre"; // Per-view cached positions — restored when switching back to a view so // "files → symbols → files" lands back on the same layout the user had. const savedPositions = { files: null, symbols: null }; function capturePositions() { const map = {}; cy.nodes().forEach((n) => { map[n.id()] = { ...n.position() }; }); savedPositions[currentView] = map; } function restorePositions(map) { cy.batch(() => { cy.nodes().forEach((n) => { const p = map[n.id()]; if (p) n.position(p); }); }); cy.fit(undefined, 50); if (cy.zoom() > 1.4) cy.zoom({ level: 1.4, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } }); } function runLayout(name) { currentLayout = name; const n = cy.nodes().length; const opts = { name, animate: n < 200, animationDuration: 250, fit: true, padding: 50 }; if (name === "dagre") { // TB (top-bottom) reads better than LR for code graphs because the // dependency chain is typically shallow but fan-in is wide. opts.rankDir = "TB"; opts.nodeSep = 28; opts.rankSep = 80; opts.edgeSep = 14; } if (name === "concentric") { opts.concentric = (node) => node.degree(); opts.levelWidth = () => 2; opts.minNodeSpacing = 30; } if (name === "breadthfirst") { opts.directed = true; opts.spacingFactor = 1.4; } const layout = cy.layout(opts); layout.one("layoutstop", () => { // Cap zoom after auto-fit so we never start in an absurd view. if (cy.zoom() > 1.4) cy.zoom({ level: 1.4, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } }); capturePositions(); }); layout.run(); } // Symbol focus-graph state — the id of the symbol the view is currently // anchored on. `null` = empty state (no seed picked yet). let symbolSeedId = null; // ── View toggle ────────────────────────────────────────────────── function switchView(next) { if (next === currentView) return; // Snapshot the view we're leaving (only the Files view uses saved // positions — Symbols view is seeded on every entry). if (currentView === "files") capturePositions(); currentView = next; clearHighlights(); closeSidebar(); document.querySelectorAll(".view-toggle button").forEach((b) => b.classList.toggle("active", b.dataset.view === next)); if (next === "files") { cy.elements().remove(); cy.add([...FILE_NODES, ...FILE_EDGES]); hideSymbolEmptyState(); if (savedPositions.files) restorePositions(savedPositions.files); else runLayout(currentLayout); return; } // Symbols view — focus graph. if (symbolSeedId && SYMBOL_BY_ID.has(symbolSeedId)) { // Re-seed with the last-used symbol so toggling back feels continuous. setSymbolSeed(symbolSeedId); } else { cy.elements().remove(); showSymbolEmptyState(); } } // ── Symbol focus graph ─────────────────────────────────────────── /** BFS a bounded neighbourhood around `seedId`, capping total size so * the focus graph stays readable. Follows both directions: * callers (incoming) and callees (outgoing) alike. */ function buildSymbolNeighbourhood(seedId, maxDepth) { if (!SYMBOL_BY_ID.has(seedId)) return new Set(); const visited = new Set([seedId]); const queue = [[seedId, 0]]; while (queue.length) { const [id, depth] = queue.shift(); if (depth >= maxDepth) continue; for (const e of OUTGOING_BY_CALLER.get(id) || []) { if (!visited.has(e.target)) { visited.add(e.target); queue.push([e.target, depth + 1]); } } for (const e of INCOMING_BY_CALLEE.get(id) || []) { if (!visited.has(e.source)) { visited.add(e.source); queue.push([e.source, depth + 1]); } } } return visited; } function setSymbolSeed(seedId) { if (!SYMBOL_BY_ID.has(seedId)) { showSymbolEmptyState(`Symbol not found: ${seedId}`); return; } symbolSeedId = seedId; // Make sure we're in Symbols view regardless of where the call came // from. If we're coming from Files, capture its positions BEFORE we // switch so toggling back to Files lands on the same layout. if (currentView !== "symbols") { if (currentView === "files") capturePositions(); currentView = "symbols"; document.querySelectorAll(".view-toggle button").forEach((b) => b.classList.toggle("active", b.dataset.view === "symbols")); } // Depth 2 by default; fall back to depth 1 if the neighbourhood // explodes (popular utility symbols can have hundreds of callers). let visited = buildSymbolNeighbourhood(seedId, 2); if (visited.size > 60) visited = buildSymbolNeighbourhood(seedId, 1); // Build Cytoscape elements for exactly this neighbourhood. const nodes = []; for (const id of visited) { const s = SYMBOL_BY_ID.get(id); if (!s) continue; nodes.push({ group: "nodes", data: { id: `s::${s.id}`, label: s.name, nodeType: "symbol", qualifiedName: s.qualifiedName, kind: s.kind, file: s.file, line: s.line }, classes: id === seedId ? "seed always-label" : "", }); } const edges = []; let edgeCounter = 0; for (const e of DATA.symbolEdges || []) { if (visited.has(e.source) && visited.has(e.target)) { edges.push({ group: "edges", data: { id: `se-${edgeCounter++}`, source: `s::${e.source}`, target: `s::${e.target}`, confidence: e.confidence, scope: "symbol" }, classes: `conf-${e.confidence}`, }); } } clearHighlights(); cy.elements().remove(); if (nodes.length <= 1 && edges.length === 0) { // Seed has no connections — show the seed alone with an explanation. if (nodes.length === 1) cy.add(nodes); showSymbolEmptyState(`${SYMBOL_BY_ID.get(seedId).name} has no resolved callers or callees in this project.`); return; } hideSymbolEmptyState(); cy.add([...nodes, ...edges]); runLayout("dagre"); openSymbol(seedId); } function showSymbolEmptyState(msg) { const el = document.getElementById("symbol-empty"); if (!el) return; el.style.display = "flex"; if (msg) { const p = el.querySelector("[data-msg]"); if (p) p.textContent = msg; } } function hideSymbolEmptyState() { const el = document.getElementById("symbol-empty"); if (el) el.style.display = "none"; } // ── Highlighting ───────────────────────────────────────────────── function clearHighlights() { cy.elements().removeClass("faded highlight highlight-flow highlight-edge highlight-flow-edge highlight-selected highlight-neighbour highlight-neighbour-edge always-label"); } /** * Default highlight on node tap: the tapped node + its direct * incomers/outgoers. Less aggressive than "Blast radius" (transitive), * just surfaces what's wired directly to this node. */ function highlightNeighbourhood(rootId) { clearHighlights(); const root = cy.getElementById(rootId); if (root.empty()) return; const neighbourhood = root.closedNeighborhood(); cy.elements().difference(neighbourhood).addClass("faded"); root.addClass("highlight-selected always-label"); neighbourhood.nodes().difference(root).addClass("highlight-neighbour always-label"); neighbourhood.edges().addClass("highlight-neighbour-edge"); } function bfsHighlight(rootId, direction) { clearHighlights(); const root = cy.getElementById(rootId); if (root.empty()) return; const visited = new Set([rootId]); const queue = [rootId]; const edges = []; while (queue.length) { const id = queue.shift(); const neighbours = direction === "reverse" ? cy.getElementById(id).incomers() : cy.getElementById(id).outgoers(); neighbours.edges().forEach((e) => edges.push(e.id())); neighbours.nodes().forEach((n) => { if (!visited.has(n.id())) { visited.add(n.id()); queue.push(n.id()); } }); } cy.elements().addClass("faded"); const nodeClass = direction === "reverse" ? "highlight" : "highlight-flow"; const edgeClass = direction === "reverse" ? "highlight-edge" : "highlight-flow-edge"; visited.forEach((id) => cy.getElementById(id).removeClass("faded").addClass(nodeClass)); edges.forEach((id) => cy.getElementById(id).removeClass("faded").addClass(edgeClass)); } const highlightImpact = (id) => bfsHighlight(id, "reverse"); const highlightFlow = (id) => bfsHighlight(id, "forward"); // ── Sidebar (safe DOM builders — no innerHTML) ─────────────────── const sidebar = document.getElementById("sidebar"); function clearSidebar() { while (sidebar.firstChild) sidebar.removeChild(sidebar.firstChild); } /** * Reset the sidebar to its default state for the current view: * - Files view → "click a node" hint * - Symbols view with no seed → alphabetical list of all symbols * (so the page isn't blank and users * have something to start from) * - Symbols view with a seed → called rarely; same hint as Files */ function closeSidebar() { clearSidebar(); if (currentView === "symbols" && !symbolSeedId) { renderSymbolList(); return; } sidebar.className = "empty"; sidebar.appendChild(document.createTextNode("Click a node to see details")); } /** Alphabetical list of all symbols — the landing-state sidebar for * Symbol view. Clicking any row seeds the focus graph on that symbol * (handled by the existing `li[data-symbol-id]` handler below). */ function renderSymbolList() { clearSidebar(); sidebar.className = "list"; const symbols = DATA.symbols || []; sidebar.appendChild(h("h2", null, "All symbols")); sidebar.appendChild(h("div", { class: "list-count" }, `${symbols.length} symbols — click to explore, or type in the search bar to jump to one.`)); const sorted = [...symbols].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); const ul = h("ul", null); for (const s of sorted) { const basename = (s.file || "").split("/").pop() || s.file || ""; const li = h("li", { "data-symbol-id": s.id, title: `${s.qualifiedName} @ ${s.file}:${s.line}` }, h("span", { style: `color:${KIND_COLORS[s.kind] || "#64748b"};font-weight:600;` }, s.kind), " ", s.name, h("span", { class: "file-suffix" }, basename), ); ul.appendChild(li); } sidebar.appendChild(ul); } /** Explicit clear for the focus graph — used by the "Back to symbol * list" link in openSymbol's sidebar output. Resets seed, removes the * graph, shows the empty-state overlay + list. */ function clearSymbolSeed() { symbolSeedId = null; clearHighlights(); cy.elements().remove(); showSymbolEmptyState(); closeSidebar(); // re-renders the list because seed is null } function actionBar(nodeId) { return h("div", { class: "actions" }, h("button", { "data-action": "impact", "data-id": nodeId }, "Blast radius"), h("button", { class: "flow", "data-action": "flow", "data-id": nodeId }, "Call flow"), h("button", { class: "reset", "data-action": "reset" }, "Clear"), ); } function metaRow(label, value) { return h("div", { class: "meta" }, h("strong", null, `${label}: `), String(value)); } function openFile(id) { const file = DATA.files.find((f) => f.id === id); if (!file) return closeSidebar(); const color = colourFor(file.language); const syms = (DATA.symbolsByFile && DATA.symbolsByFile[id]) || []; const preview = syms.slice(0, 30); clearSidebar(); sidebar.className = ""; sidebar.appendChild(h("h2", null, file.label)); sidebar.appendChild(h("div", { style: "font-size:11px;color:#6b7280;margin-bottom:8px;word-break:break-all;" }, file.id)); sidebar.appendChild(h("span", { class: "lang-badge", style: `background:${color}` }, file.language)); const connections = h("div", { class: "section" }, h("h3", null, "Connections"), h("div", { class: "meta" }, "imports: ", h("strong", null, String(file.deps)), " · imported by: ", h("strong", null, String(file.dependents)), " · symbols: ", h("strong", null, String(file.symbolCount || 0)), ), ); sidebar.appendChild(connections); if (syms.length > 0) { const section = h("div", { class: "section" }, h("h3", null, `Symbols (${syms.length})`), ); const ul = h("ul", null); for (const s of preview) { const li = h("li", { "data-symbol-id": s.id, title: `${s.qualifiedName}:${s.line}` }, h("span", { style: `color:${KIND_COLORS[s.kind] || "#64748b"};font-weight:600;` }, s.kind), " ", s.name, h("span", { style: "color:#94a3b8" }, `:${s.line}`), ); ul.appendChild(li); } section.appendChild(ul); if (syms.length > preview.length) { section.appendChild(h("div", { style: "color:#9ca3af;font-size:11px;margin-top:4px" }, `+ ${syms.length - preview.length} more — use codebase_symbols to list all`)); } sidebar.appendChild(section); } sidebar.appendChild(actionBar(`f::${file.id}`)); } function openSymbol(id) { const sym = (DATA.symbols || []).find((s) => s.id === id); if (!sym) return closeSidebar(); clearSidebar(); sidebar.className = ""; // Back link at the top — the deliberate way out of the focus graph, // visible in the one place users look when they want to exit (top of // the sidebar). Clicking empty canvas no longer hides this. if (currentView === "symbols") { sidebar.appendChild(h("button", { class: "back-btn", "data-action": "back-to-list" }, "← Back to symbol list")); } sidebar.appendChild(h("h2", null, sym.name)); sidebar.appendChild(h("span", { class: "kind-badge" }, sym.kind)); sidebar.appendChild(metaRow("Qualified", sym.qualifiedName)); sidebar.appendChild(h("div", { class: "meta" }, h("strong", null, "File: "), h("code", null, sym.file))); sidebar.appendChild(metaRow("Line", sym.line)); sidebar.appendChild(actionBar(`s::${sym.id}`)); } // ── Event wiring ───────────────────────────────────────────────── cy.on("tap", "node", (evt) => { const n = evt.target; if (n.data("nodeType") === "file") { // File view: highlight the direct neighbourhood + open sidebar. highlightNeighbourhood(n.id()); openFile(n.data("file")); } else { // Symbol view: clicking a non-seed node RE-CENTRES the focus graph // on that symbol (SourceTrail-style exploration). Clicking the // current seed just refreshes the sidebar. const symId = n.id().slice(3); if (symId !== symbolSeedId) { setSymbolSeed(symId); } else { openSymbol(symId); } } }); // Empty-canvas tap — clears highlights. In Symbol view with an active // seed we deliberately DO NOT close the sidebar, so the "Back to symbol // list" link stays visible (accidental-click-destroys-context is a // common graph-viewer anti-pattern). cy.on("tap", (evt) => { if (evt.target !== cy) return; clearHighlights(); if (currentView === "symbols" && symbolSeedId) return; // keep sidebar closeSidebar(); }); // Keep the `currentZoom` module variable in sync and re-render labels // when it crosses the threshold (the style functions read it). currentZoom = cy.zoom(); let lastLabelState = currentZoom >= LABEL_ZOOM_THRESHOLD; cy.on("zoom", () => { currentZoom = cy.zoom(); const state = currentZoom >= LABEL_ZOOM_THRESHOLD; if (state !== lastLabelState) { lastLabelState = state; cy.style().update(); } }); sidebar.addEventListener("click", (evt) => { const btn = evt.target.closest("button[data-action]"); if (btn) { const action = btn.dataset.action; if (action === "impact") highlightImpact(btn.dataset.id); else if (action === "flow") highlightFlow(btn.dataset.id); else if (action === "reset") clearHighlights(); else if (action === "back-to-list") clearSymbolSeed(); return; } // Clicking a symbol in either (a) a file's sidebar list in File view, // or (b) the all-symbols list in Symbol empty state, seeds the focus // graph on that symbol. Same action inside Symbol view re-centres. const li = evt.target.closest("li[data-symbol-id]"); if (li) setSymbolSeed(li.dataset.symbolId); }); // ── Toolbar wiring ─────────────────────────────────────────────── document.getElementById("layout").addEventListener("change", (e) => runLayout(e.target.value)); document.querySelectorAll(".view-toggle button").forEach((b) => b.addEventListener("click", () => switchView(b.dataset.view))); document.getElementById("fit").addEventListener("click", () => cy.fit(undefined, 30)); document.getElementById("reset").addEventListener("click", () => { clearHighlights(); closeSidebar(); cy.fit(undefined, 30); }); document.getElementById("export").addEventListener("click", () => { const png = cy.png({ full: true, scale: 2, bg: "#f8fafc" }); const a = document.createElement("a"); // Sanitise the project name for cross-platform filesystem safety: // strip characters forbidden on Windows (<>:"/\\|?*), control chars, // collapse whitespace, cap length, and fall back to "graph" if empty. const rawName = String(DATA.project.name || "graph"); const safeName = rawName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim().slice(0, 120) || "graph"; a.href = png; a.download = `${safeName}.png`; a.click(); }); // ── Live search ────────────────────────────────────────────────── // File view → live-filter visible file nodes, highlight + fit matches. // Symbol view → debounced search across ALL symbols (not just what's // on screen); best match seeds the focus graph. let searchDebounce = null; document.getElementById("search").addEventListener("input", (e) => { const q = e.target.value.trim().toLowerCase(); if (currentView === "files") { clearHighlights(); if (!q) return; const matches = cy.nodes().filter((n) => { const label = (n.data("label") || "").toLowerCase(); const file = (n.data("file") || "").toLowerCase(); return label.includes(q) || file.includes(q); }); if (matches.length === 0) return; cy.elements().addClass("faded"); matches.removeClass("faded").addClass("highlight"); if (matches.length <= 20) cy.animate({ fit: { eles: matches, padding: 80 } }, { duration: 300 }); return; } // Symbols view: debounce so we don't re-layout on every keystroke. if (searchDebounce) clearTimeout(searchDebounce); if (!q) { if (!symbolSeedId) showSymbolEmptyState(); return; } searchDebounce = setTimeout(() => { // Prefer exact name match, then qualified-name match, then substring. const symbols = DATA.symbols || []; const exact = symbols.find((s) => s.name.toLowerCase() === q); const qname = exact || symbols.find((s) => s.qualifiedName.toLowerCase() === q); const sub = qname || symbols.find((s) => s.name.toLowerCase().includes(q) || s.qualifiedName.toLowerCase().includes(q), ); if (sub) setSymbolSeed(sub.id); else showSymbolEmptyState(`No symbol matching "${q}".`); }, 250); }); // ── Tooltip on hover ───────────────────────────────────────────── const tooltip = document.getElementById("tooltip"); cy.on("mouseover", "node", (evt) => { const n = evt.target; const pos = evt.renderedPosition || n.renderedPosition(); tooltip.textContent = n.data("nodeType") === "file" ? `${n.data("file")} — ${n.data("language")}` : `${n.data("qualifiedName")} — ${n.data("kind")} @ ${n.data("file")}:${n.data("line")}`; tooltip.style.left = `${pos.x + 14}px`; tooltip.style.top = `${pos.y + 14}px`; tooltip.style.opacity = "1"; }); cy.on("mouseout", "node", () => { tooltip.style.opacity = "0"; }); // Right-click node → blast radius cy.on("cxttap", "node", (evt) => highlightImpact(evt.target.id())); // ── Symbols-unavailable banner ─────────────────────────────────── const banner = document.getElementById("banner"); if (DATA.symbolMode !== "full") { banner.classList.add("visible"); banner.textContent = DATA.symbolMode === "omitted" ? `Symbol view disabled: ${DATA.symbolOmitReason || "no symbol graph available"}. Run codebase_graph_build first.` : `Symbol view partial (${DATA.symbolOmitReason}). Use codebase_symbols / codebase_impact for full detail.`; if (DATA.symbolMode === "omitted") { document.querySelectorAll('.view-toggle button[data-view="symbols"]').forEach((b) => { b.disabled = true; b.title = "No symbol graph available"; }); } } // ── Initial layout + sidebar ───────────────────────────────────── runLayout(currentLayout); closeSidebar(); })();