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
JavaScript
/* 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();
})();