<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>tokensave visualizer</title>
<script src="https://unpkg.com/cytoscape@3.30.4/dist/cytoscape.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; background: #1a1b26; color: #c0caf5; display: flex; flex-direction: column; height: 100vh; }
#header { padding: 12px 20px; background: #24283b; border-bottom: 1px solid #3b4261; display: flex; align-items: center; gap: 16px; }
#header h1 { font-size: 16px; font-weight: 600; color: #7aa2f7; }
#search-form { display: flex; gap: 8px; flex: 1; max-width: 600px; }
#search-input { flex: 1; padding: 8px 12px; background: #1a1b26; border: 1px solid #3b4261; border-radius: 6px; color: #c0caf5; font-size: 14px; outline: none; }
#search-input:focus { border-color: #7aa2f7; }
#search-btn { padding: 8px 16px; background: #7aa2f7; color: #1a1b26; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 14px; }
#search-btn:hover { background: #89b4fa; }
#main { display: flex; flex: 1; overflow: hidden; }
#cy { flex: 1; background: #1a1b26; }
#detail { width: 420px; background: #24283b; border-left: 1px solid #3b4261; overflow-y: auto; padding: 16px; display: none; }
#detail h2 { font-size: 14px; color: #7aa2f7; margin-bottom: 8px; }
#detail .kind { font-size: 12px; color: #9ece6a; background: #1a1b26; padding: 2px 8px; border-radius: 4px; display: inline-block; margin-bottom: 8px; }
#detail .file { font-size: 12px; color: #565f89; margin-bottom: 12px; }
#detail .sig { font-size: 13px; color: #bb9af7; background: #1a1b26; padding: 8px; border-radius: 4px; margin-bottom: 12px; font-family: monospace; white-space: pre-wrap; word-break: break-all; }
#detail .actions { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
#detail .actions button { padding: 4px 10px; background: #3b4261; color: #c0caf5; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; }
#detail .actions button:hover { background: #565f89; }
#status-bar { padding: 6px 20px; background: #24283b; border-top: 1px solid #3b4261; font-size: 12px; color: #565f89; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #565f89; font-size: 14px; gap: 8px; }
#ctx-menu { position: fixed; display: none; background: #24283b; border: 1px solid #3b4261; border-radius: 6px; padding: 4px 0; min-width: 160px; z-index: 1000; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
#ctx-menu .ctx-item { padding: 6px 14px; font-size: 13px; color: #c0caf5; cursor: pointer; }
#ctx-menu .ctx-item:hover { background: #3b4261; }
#ctx-menu .ctx-header { padding: 6px 14px 4px; font-size: 11px; color: #565f89; border-bottom: 1px solid #3b4261; margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; }
</style>
</head>
<body>
<div id="header">
<h1>tokensave</h1>
<form id="search-form">
<input id="search-input" type="text" placeholder="Search symbols or ask a question..." autocomplete="off">
<button id="search-btn" type="submit">Explore</button>
</form>
</div>
<div id="main">
<div id="cy">
<div class="empty-state" id="empty-state">
<div>Type a query to explore your code graph</div>
<div style="font-size:12px">Try: "authentication", "database", or a function name</div>
</div>
</div>
<div id="detail">
<h2 id="detail-name"></h2>
<span class="kind" id="detail-kind"></span>
<div class="file" id="detail-file"></div>
<div class="sig" id="detail-sig" style="display:none"></div>
<div class="actions" id="detail-actions"></div>
</div>
</div>
<div id="ctx-menu"></div>
<div id="status-bar">Loading...</div>
<script>
const cy = cytoscape({
container: document.getElementById('cy'),
style: [
{ selector: 'node', style: {
'label': 'data(name)',
'background-color': '#7aa2f7',
'color': '#c0caf5',
'font-size': '11px',
'text-valign': 'bottom',
'text-margin-y': 6,
'text-max-width': 200,
'text-wrap': 'ellipsis',
'width': 24, 'height': 24,
'border-width': 2, 'border-color': '#3b4261',
}},
{ selector: 'node[kind="class"], node[kind="struct"], node[kind="interface"], node[kind="trait"]', style: {
'background-color': '#bb9af7', 'shape': 'round-rectangle', 'width': 30, 'height': 24,
}},
{ selector: 'node[kind="method"], node[kind="function"], node[kind="arrow_function"]', style: {
'background-color': '#7aa2f7',
}},
{ selector: 'node[kind="module"], node[kind="file"]', style: {
'background-color': '#9ece6a', 'shape': 'diamond',
}},
{ selector: 'node.root', style: {
'border-color': '#f7768e', 'border-width': 3, 'width': 30, 'height': 30,
}},
{ selector: 'node:selected', style: {
'border-color': '#ff9e64', 'border-width': 3,
}},
{ selector: 'edge', style: {
'width': 1.5,
'line-color': '#3b4261',
'target-arrow-color': '#3b4261',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'arrow-scale': 0.8,
}},
{ selector: 'edge[kind="calls"]', style: { 'line-color': '#7aa2f7', 'target-arrow-color': '#7aa2f7' }},
{ selector: 'edge[kind="contains"]', style: { 'line-color': '#3b4261', 'line-style': 'dashed' }},
{ selector: 'edge[kind="extends"], edge[kind="implements"]', style: { 'line-color': '#bb9af7', 'target-arrow-color': '#bb9af7' }},
],
layout: { name: 'cose', animate: false, nodeDimensionsIncludeLabels: true },
minZoom: 0.2, maxZoom: 5,
});
fetch('/api/status').then(r=>r.json()).then(d=>{
const s = d.stats;
document.getElementById('status-bar').textContent =
`${s.node_count.toLocaleString()} nodes | ${s.edge_count.toLocaleString()} edges | ${s.file_count.toLocaleString()} files | ${d.projectRoot}`;
}).catch(()=>{
document.getElementById('status-bar').textContent = 'Failed to load status';
});
document.getElementById('search-form').addEventListener('submit', async (e) => {
e.preventDefault();
const q = document.getElementById('search-input').value.trim();
if (!q) return;
document.getElementById('empty-state').style.display = 'none';
document.getElementById('status-bar').textContent = 'Exploring...';
try {
const res = await fetch(`/api/explore?q=${encodeURIComponent(q)}`);
const data = await res.json();
renderGraph(data);
document.getElementById('status-bar').textContent =
`${data.nodes.length} nodes, ${data.edges.length} edges`;
} catch (err) {
document.getElementById('status-bar').textContent = `Error: ${err.message}`;
}
});
function renderGraph(data) {
cy.elements().remove();
const elements = [];
for (const node of data.nodes) {
elements.push({
data: {
id: node.id,
kind: node.kind,
name: node.name,
file: node.file_path,
line: node.start_line,
sig: node.signature || '',
},
classes: data.roots && data.roots.includes(node.id) ? 'root' : '',
});
}
for (const edge of data.edges) {
elements.push({
data: { source: edge.source, target: edge.target, kind: edge.kind },
});
}
cy.add(elements);
cy.layout({ name: 'cose', animate: false, nodeDimensionsIncludeLabels: true, padding: 40 }).run();
}
cy.on('tap', 'node', async function(evt) {
const node = evt.target;
const d = node.data();
document.getElementById('detail').style.display = 'block';
document.getElementById('detail-name').textContent = d.name;
document.getElementById('detail-kind').textContent = d.kind;
document.getElementById('detail-file').textContent = `${d.file}:${d.line}`;
if (d.sig) {
document.getElementById('detail-sig').textContent = d.sig;
document.getElementById('detail-sig').style.display = 'block';
} else {
document.getElementById('detail-sig').style.display = 'none';
}
const actions = document.getElementById('detail-actions');
actions.innerHTML = '';
const btns = [
{ label: 'Callers', endpoint: 'callers' },
{ label: 'Callees', endpoint: 'callees' },
{ label: 'Call Graph', endpoint: 'callgraph' },
{ label: 'Impact', endpoint: 'impact' },
];
for (const btn of btns) {
const b = document.createElement('button');
b.textContent = btn.label;
b.onclick = async () => {
const res = await fetch(`/api/node/${encodeURIComponent(d.id)}/${btn.endpoint}?depth=2`);
const data = await res.json();
renderGraph(data);
document.getElementById('status-bar').textContent =
`${btn.label} of ${d.name}: ${data.nodes.length} nodes`;
};
actions.appendChild(b);
}
});
cy.on('tap', function(evt) {
if (evt.target === cy) {
document.getElementById('detail').style.display = 'none';
}
});
const ctxMenu = document.getElementById('ctx-menu');
let ctxNodeData = null;
function hideCtxMenu() { ctxMenu.style.display = 'none'; ctxNodeData = null; }
function showCtxMenu(x, y, d) {
ctxNodeData = d;
const items = [
{ label: 'Show Callers', endpoint: 'callers' },
{ label: 'Show Callees', endpoint: 'callees' },
{ label: 'Show Call Graph', endpoint: 'callgraph' },
{ label: 'Show Impact', endpoint: 'impact' },
];
ctxMenu.innerHTML = `<div class="ctx-header" title="${d.name}">${d.name}</div>` +
items.map(i => `<div class="ctx-item" data-ep="${i.endpoint}">${i.label}</div>`).join('');
ctxMenu.style.display = 'block';
const rect = ctxMenu.getBoundingClientRect();
const mx = Math.min(x, window.innerWidth - rect.width - 4);
const my = Math.min(y, window.innerHeight - rect.height - 4);
ctxMenu.style.left = mx + 'px';
ctxMenu.style.top = my + 'px';
}
ctxMenu.addEventListener('click', async (e) => {
const item = e.target.closest('.ctx-item');
if (!item || !ctxNodeData) return;
const ep = item.dataset.ep;
const d = ctxNodeData;
hideCtxMenu();
try {
const res = await fetch(`/api/node/${encodeURIComponent(d.id)}/${ep}?depth=2`);
const data = await res.json();
renderGraph(data);
const label = item.textContent;
document.getElementById('status-bar').textContent =
`${label.replace('Show ', '')} of ${d.name}: ${data.nodes.length} nodes`;
} catch (err) {
document.getElementById('status-bar').textContent = `Error: ${err.message}`;
}
});
cy.on('cxttap', 'node', function(evt) {
const d = evt.target.data();
const pos = evt.originalEvent;
showCtxMenu(pos.clientX, pos.clientY, d);
});
document.addEventListener('click', (e) => { if (!ctxMenu.contains(e.target)) hideCtxMenu(); });
cy.on('tap pan zoom', hideCtxMenu);
document.getElementById('cy').addEventListener('contextmenu', (e) => e.preventDefault());
</script>
</body>
</html>