AI picking the entry point was unreliable. Now: - Type a symbol name → dropdown shows matches - Click a result (or Enter to pick first) → traces its call graph depth 3 - The user picks the starting point, the graph does the rest deterministically No more AI guesswork. Search + graph traversal = reliable flows. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1995 lines
64 KiB
HTML
1995 lines
64 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>CodeGraph Explorer</title>
|
|
|
|
<!-- Cytoscape.js + Dagre layout -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.30.4/cytoscape.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/dagre@0.8.5/dist/dagre.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.js"></script>
|
|
|
|
<!-- Highlight.js for code syntax highlighting -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" />
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
|
|
<style>
|
|
/* ====================================================================
|
|
CSS Reset & Base
|
|
==================================================================== */
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
:root {
|
|
--bg-primary: #0d1117;
|
|
--bg-secondary: #161b22;
|
|
--bg-tertiary: #1c2128;
|
|
--bg-hover: #1f2937;
|
|
--border: #30363d;
|
|
--border-light: #3d444d;
|
|
--text-primary: #e6edf3;
|
|
--text-secondary: #8b949e;
|
|
--text-muted: #656d76;
|
|
--accent: #58a6ff;
|
|
--accent-hover: #79c0ff;
|
|
--green: #3fb950;
|
|
--purple: #d2a8ff;
|
|
--orange: #ffa657;
|
|
--red: #ff7b72;
|
|
--yellow: #d29922;
|
|
--pink: #f778ba;
|
|
--cyan: #76e3ea;
|
|
--font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', Consolas, monospace;
|
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
--sidebar-width: 320px;
|
|
--panel-width: 460px;
|
|
--header-height: 52px;
|
|
--radius: 8px;
|
|
--radius-sm: 6px;
|
|
}
|
|
|
|
html, body {
|
|
height: 100%;
|
|
font-family: var(--font-sans);
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ====================================================================
|
|
Layout
|
|
==================================================================== */
|
|
#app {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
}
|
|
|
|
/* Header */
|
|
#header {
|
|
height: var(--header-height);
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 16px;
|
|
gap: 16px;
|
|
flex-shrink: 0;
|
|
z-index: 10;
|
|
}
|
|
|
|
#header .logo {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
#header .logo span.icon { font-size: 20px; }
|
|
|
|
#search-container {
|
|
flex: 1;
|
|
max-width: 560px;
|
|
position: relative;
|
|
}
|
|
|
|
#search-input {
|
|
width: 100%;
|
|
height: 34px;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
padding: 0 12px 0 34px;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
font-family: var(--font-sans);
|
|
}
|
|
|
|
#search-input:focus { border-color: var(--accent); }
|
|
|
|
#search-container .search-icon {
|
|
position: absolute;
|
|
left: 10px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: var(--text-muted);
|
|
font-size: 14px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
#search-results-dropdown {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
margin-top: 4px;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
z-index: 100;
|
|
display: none;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
}
|
|
|
|
#search-results-dropdown.visible { display: block; }
|
|
|
|
.search-result-item {
|
|
padding: 8px 12px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
transition: background 0.1s;
|
|
}
|
|
|
|
.search-result-item:last-child { border-bottom: none; }
|
|
.search-result-item:hover { background: var(--bg-hover); }
|
|
|
|
.search-result-item .kind-badge {
|
|
font-size: 10px;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.search-result-item .name {
|
|
font-weight: 500;
|
|
font-size: 13px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.search-result-item .file-path {
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
margin-left: auto;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 200px;
|
|
}
|
|
|
|
#header-stats {
|
|
display: flex;
|
|
gap: 12px;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
#header-stats .stat { display: flex; align-items: center; gap: 4px; }
|
|
#header-stats .stat-value { color: var(--text-secondary); font-weight: 500; }
|
|
|
|
/* Main content */
|
|
#main {
|
|
display: flex;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Sidebar */
|
|
#sidebar {
|
|
width: var(--sidebar-width);
|
|
background: var(--bg-secondary);
|
|
border-right: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-shrink: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sidebar-section {
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.sidebar-header {
|
|
padding: 10px 14px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: var(--text-muted);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.sidebar-header:hover { color: var(--text-secondary); }
|
|
|
|
.sidebar-content {
|
|
overflow-y: auto;
|
|
max-height: 300px;
|
|
}
|
|
|
|
#file-tree {
|
|
padding: 4px 0;
|
|
}
|
|
|
|
.file-item {
|
|
padding: 5px 14px;
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
transition: background 0.1s;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.file-item:hover { background: var(--bg-hover); color: var(--text-primary); }
|
|
.file-item.active { background: var(--bg-hover); color: var(--accent); }
|
|
|
|
.file-item .file-icon { font-size: 12px; flex-shrink: 0; }
|
|
|
|
/* Graph legend */
|
|
#legend {
|
|
padding: 10px 14px;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 3px 0;
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.legend-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Graph toolbar */
|
|
#graph-toolbar {
|
|
padding: 8px 14px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
|
|
.toolbar-btn {
|
|
padding: 4px 10px;
|
|
font-size: 11px;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
font-family: var(--font-sans);
|
|
}
|
|
|
|
.toolbar-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
border-color: var(--border-light);
|
|
}
|
|
|
|
.toolbar-btn.active {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
/* Graph canvas */
|
|
#graph-container {
|
|
flex: 1;
|
|
position: relative;
|
|
background: var(--bg-primary);
|
|
overflow: hidden;
|
|
}
|
|
|
|
#cy {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
#graph-overlay {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
text-align: center;
|
|
color: var(--text-muted);
|
|
pointer-events: none;
|
|
}
|
|
|
|
#graph-overlay .overlay-icon { font-size: 48px; margin-bottom: 12px; }
|
|
#graph-overlay .overlay-title { font-size: 18px; font-weight: 500; margin-bottom: 6px; }
|
|
#graph-overlay .overlay-subtitle { font-size: 13px; }
|
|
|
|
.example-btn {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 20px;
|
|
color: var(--text-secondary);
|
|
padding: 6px 16px;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
font-family: var(--font-sans);
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.example-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--accent);
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
/* Breadcrumbs */
|
|
#breadcrumbs {
|
|
position: absolute;
|
|
top: 10px;
|
|
left: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 12px;
|
|
z-index: 5;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 6px 10px;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
pointer-events: none;
|
|
}
|
|
|
|
#breadcrumbs.visible { opacity: 1; pointer-events: auto; }
|
|
|
|
.breadcrumb-item {
|
|
color: var(--accent);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.breadcrumb-item:hover { text-decoration: underline; }
|
|
.breadcrumb-sep { color: var(--text-muted); }
|
|
|
|
/* Graph controls */
|
|
#graph-controls {
|
|
position: absolute;
|
|
bottom: 14px;
|
|
right: 14px;
|
|
display: flex;
|
|
gap: 4px;
|
|
z-index: 5;
|
|
}
|
|
|
|
.graph-ctrl-btn {
|
|
width: 32px;
|
|
height: 32px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 16px;
|
|
transition: all 0.15s;
|
|
font-family: var(--font-sans);
|
|
}
|
|
|
|
.graph-ctrl-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Context menu */
|
|
#context-menu {
|
|
position: fixed;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 4px 0;
|
|
z-index: 200;
|
|
display: none;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
|
min-width: 180px;
|
|
}
|
|
|
|
#context-menu.visible { display: block; }
|
|
|
|
.ctx-item {
|
|
padding: 7px 14px;
|
|
font-size: 13px;
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
transition: background 0.1s;
|
|
}
|
|
|
|
.ctx-item:hover { background: var(--bg-hover); }
|
|
.ctx-item .ctx-icon { font-size: 14px; width: 18px; text-align: center; }
|
|
.ctx-sep { height: 1px; background: var(--border); margin: 4px 0; }
|
|
|
|
/* Detail panel */
|
|
#detail-panel {
|
|
width: 0;
|
|
background: var(--bg-secondary);
|
|
border-left: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
overflow: hidden;
|
|
transition: width 0.2s ease;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
#detail-panel.open { width: var(--panel-width); }
|
|
|
|
#detail-header {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#detail-header .node-title {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
word-break: break-all;
|
|
}
|
|
|
|
#detail-header .close-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
font-size: 18px;
|
|
padding: 0 4px;
|
|
line-height: 1;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#detail-header .close-btn:hover { color: var(--text-primary); }
|
|
|
|
#detail-body {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0;
|
|
}
|
|
|
|
.detail-section {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.detail-section-title {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: var(--text-muted);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.detail-meta {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: 4px 12px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.detail-meta .label { color: var(--text-muted); }
|
|
.detail-meta .value { color: var(--text-secondary); word-break: break-all; }
|
|
.detail-meta .value.accent { color: var(--accent); }
|
|
|
|
/* Code block */
|
|
.code-block {
|
|
background: var(--bg-primary);
|
|
border-radius: var(--radius-sm);
|
|
overflow-x: auto;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.code-block pre {
|
|
margin: 0;
|
|
padding: 12px;
|
|
}
|
|
|
|
.code-block code {
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
/* Relations list */
|
|
.relation-list { list-style: none; }
|
|
|
|
.relation-item {
|
|
padding: 5px 0;
|
|
font-size: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
cursor: pointer;
|
|
transition: color 0.1s;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.relation-item:hover { color: var(--accent); }
|
|
|
|
.relation-item .rel-badge {
|
|
font-size: 9px;
|
|
padding: 1px 5px;
|
|
border-radius: 3px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
/* Loading spinner */
|
|
.spinner {
|
|
display: inline-block;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin 0.6s linear infinite;
|
|
}
|
|
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
/* Scrollbar */
|
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
|
|
|
|
/* Tooltip */
|
|
.cy-tooltip {
|
|
position: fixed;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 6px 10px;
|
|
font-size: 12px;
|
|
color: var(--text-primary);
|
|
pointer-events: none;
|
|
z-index: 50;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
max-width: 300px;
|
|
display: none;
|
|
}
|
|
|
|
.cy-tooltip .tip-kind {
|
|
font-size: 10px;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.cy-tooltip .tip-name { font-weight: 500; }
|
|
.cy-tooltip .tip-file { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
|
|
|
/* Notification toast */
|
|
#toast {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
left: 50%;
|
|
transform: translateX(-50%) translateY(80px);
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 10px 20px;
|
|
font-size: 13px;
|
|
color: var(--text-primary);
|
|
z-index: 300;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
transition: transform 0.3s ease;
|
|
}
|
|
|
|
#toast.visible { transform: translateX(-50%) translateY(0); }
|
|
|
|
/* Embeddings setup dialog */
|
|
#dialog-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.7);
|
|
z-index: 500;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
|
|
#dialog-overlay.visible { display: flex; }
|
|
|
|
#dialog {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 32px;
|
|
max-width: 480px;
|
|
width: 90%;
|
|
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
|
|
}
|
|
|
|
#dialog .dialog-icon { font-size: 36px; margin-bottom: 16px; }
|
|
|
|
#dialog .dialog-title {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
#dialog .dialog-body {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
line-height: 1.6;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
#dialog .dialog-body strong { color: var(--text-primary); }
|
|
|
|
#dialog-progress {
|
|
display: none;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
#dialog-progress .progress-bar-track {
|
|
width: 100%;
|
|
height: 8px;
|
|
background: var(--bg-primary);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
#dialog-progress .progress-bar-fill {
|
|
height: 100%;
|
|
background: var(--accent);
|
|
border-radius: 4px;
|
|
width: 0%;
|
|
transition: width 0.2s ease;
|
|
}
|
|
|
|
#dialog-progress .progress-text {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
#dialog-progress .progress-percent {
|
|
float: right;
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
#dialog .dialog-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
#dialog .btn {
|
|
padding: 8px 20px;
|
|
border-radius: var(--radius-sm);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
border: 1px solid var(--border);
|
|
transition: all 0.15s;
|
|
font-family: var(--font-sans);
|
|
}
|
|
|
|
#dialog .btn-primary {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
#dialog .btn-primary:hover { background: var(--accent-hover); }
|
|
#dialog .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
#dialog .btn-secondary {
|
|
background: var(--bg-primary);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
#dialog .btn-secondary:hover { color: var(--text-primary); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<!-- Header -->
|
|
<div id="header">
|
|
<div class="logo">
|
|
<span class="icon">🔮</span>
|
|
<span>CodeGraph</span>
|
|
</div>
|
|
|
|
<div id="search-container">
|
|
<span class="search-icon">🔍</span>
|
|
<input id="search-input" type="text" placeholder="Search symbols... (Ctrl+K)" autocomplete="off" spellcheck="false" />
|
|
<div id="search-results-dropdown"></div>
|
|
</div>
|
|
|
|
<div id="header-stats">
|
|
<div class="stat"><span>Nodes:</span> <span class="stat-value" id="stat-nodes">-</span></div>
|
|
<div class="stat"><span>Edges:</span> <span class="stat-value" id="stat-edges">-</span></div>
|
|
<div class="stat"><span>Files:</span> <span class="stat-value" id="stat-files">-</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main content -->
|
|
<div id="main">
|
|
<!-- Sidebar -->
|
|
<div id="sidebar">
|
|
<!-- Graph controls section -->
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-header">
|
|
<span>Graph Actions</span>
|
|
</div>
|
|
<div id="graph-toolbar">
|
|
<button class="toolbar-btn" onclick="loadOverview()" title="Show top-level symbols">Overview</button>
|
|
<button class="toolbar-btn" onclick="clearGraph()" title="Clear the graph">Clear</button>
|
|
<button class="toolbar-btn" onclick="runLayout()" title="Re-run layout">Layout</button>
|
|
<button class="toolbar-btn" onclick="fitGraph()" title="Fit graph to view">Fit</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Legend -->
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-header" onclick="toggleSection(this)">
|
|
<span>Legend</span>
|
|
<span>▶</span>
|
|
</div>
|
|
<div class="sidebar-content" id="legend" style="display:none;"></div>
|
|
</div>
|
|
|
|
<!-- Files -->
|
|
<div class="sidebar-section" style="flex:1; overflow:hidden; display:flex; flex-direction:column;">
|
|
<div class="sidebar-header" onclick="toggleSection(this)">
|
|
<span>Files</span>
|
|
<span>▼</span>
|
|
</div>
|
|
<div class="sidebar-content" id="file-tree" style="flex:1; max-height:none;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Graph area -->
|
|
<div id="graph-container">
|
|
<div id="cy"></div>
|
|
<div id="graph-overlay">
|
|
<div class="overlay-icon">🔮</div>
|
|
<div class="overlay-title">Search for a starting point</div>
|
|
<div class="overlay-subtitle">Type a symbol name, pick it, and trace its call chain</div>
|
|
</div>
|
|
<div id="breadcrumbs"></div>
|
|
<div id="graph-controls">
|
|
<button class="graph-ctrl-btn" onclick="cy.zoom(cy.zoom() * 1.3); cy.center()" title="Zoom in">+</button>
|
|
<button class="graph-ctrl-btn" onclick="cy.zoom(cy.zoom() / 1.3); cy.center()" title="Zoom out">−</button>
|
|
<button class="graph-ctrl-btn" onclick="fitGraph()" title="Fit to view">⤢</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detail panel -->
|
|
<div id="detail-panel">
|
|
<div id="detail-header">
|
|
<div>
|
|
<div class="node-title" id="detail-title">-</div>
|
|
</div>
|
|
<button class="close-btn" onclick="closeDetailPanel()">×</button>
|
|
</div>
|
|
<div id="detail-body"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Embeddings setup dialog -->
|
|
<div id="dialog-overlay">
|
|
<div id="dialog">
|
|
<div class="dialog-icon">🧠</div>
|
|
<div class="dialog-title" id="dialog-title">Enable Semantic Search</div>
|
|
<div class="dialog-body" id="dialog-body">
|
|
CodeGraph Explorer uses <strong>semantic embeddings</strong> to understand your code by meaning, not just keywords.
|
|
This lets you ask questions like "how does authentication work?" and get accurate results.
|
|
<br><br>
|
|
This is a <strong>one-time setup</strong> that generates a local embedding model for this project. No data leaves your machine.
|
|
</div>
|
|
<div id="dialog-progress">
|
|
<div class="progress-bar-track">
|
|
<div class="progress-bar-fill" id="dialog-progress-fill"></div>
|
|
</div>
|
|
<div class="progress-text">
|
|
<span id="dialog-progress-text">Preparing...</span>
|
|
<span class="progress-percent" id="dialog-progress-percent">0%</span>
|
|
</div>
|
|
</div>
|
|
<div class="dialog-actions" id="dialog-actions">
|
|
<button class="btn btn-secondary" id="dialog-skip" onclick="closeDialog()">Skip for now</button>
|
|
<button class="btn btn-primary" id="dialog-enable" onclick="startEmbeddings()">Enable Semantic Search</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Context menu -->
|
|
<div id="context-menu">
|
|
<div class="ctx-item" onclick="ctxAction('expand-callees')"><span class="ctx-icon">→</span> Expand Callees</div>
|
|
<div class="ctx-item" onclick="ctxAction('expand-callers')"><span class="ctx-icon">←</span> Expand Callers</div>
|
|
<div class="ctx-sep"></div>
|
|
<div class="ctx-item" onclick="ctxAction('callgraph')"><span class="ctx-icon">🌐</span> Full Call Graph</div>
|
|
<div class="ctx-item" onclick="ctxAction('impact')"><span class="ctx-icon">💥</span> Impact Analysis</div>
|
|
<div class="ctx-sep"></div>
|
|
<div class="ctx-item" onclick="ctxAction('children')"><span class="ctx-icon">📂</span> Show Children</div>
|
|
<div class="ctx-item" onclick="ctxAction('details')"><span class="ctx-icon">📋</span> View Details</div>
|
|
<div class="ctx-sep"></div>
|
|
<div class="ctx-item" onclick="ctxAction('remove')"><span class="ctx-icon">✖</span> Remove from Graph</div>
|
|
</div>
|
|
|
|
<!-- Tooltip -->
|
|
<div class="cy-tooltip" id="tooltip"></div>
|
|
|
|
<!-- Toast -->
|
|
<div id="toast"></div>
|
|
|
|
<script>
|
|
// ====================================================================
|
|
// State
|
|
// ====================================================================
|
|
let cy;
|
|
let ctxNodeId = null;
|
|
let searchDebounce = null;
|
|
const expandedSets = { callers: new Set(), callees: new Set() };
|
|
|
|
// Node kind → color mapping
|
|
const kindColors = {
|
|
'function': '#79c0ff',
|
|
'method': '#7ee787',
|
|
'class': '#d2a8ff',
|
|
'interface': '#ffa657',
|
|
'struct': '#ffa657',
|
|
'trait': '#ffa657',
|
|
'protocol': '#ffa657',
|
|
'component': '#f778ba',
|
|
'enum': '#d29922',
|
|
'enum_member': '#d29922',
|
|
'type_alias': '#d2a8ff',
|
|
'variable': '#ff7b72',
|
|
'constant': '#ff7b72',
|
|
'property': '#76e3ea',
|
|
'field': '#76e3ea',
|
|
'file': '#8b949e',
|
|
'module': '#8b949e',
|
|
'namespace': '#8b949e',
|
|
'import': '#f0883e',
|
|
'export': '#3fb950',
|
|
'route': '#f778ba',
|
|
'parameter': '#8b949e',
|
|
};
|
|
|
|
const kindShapes = {
|
|
'class': 'round-rectangle',
|
|
'interface': 'round-diamond',
|
|
'struct': 'round-rectangle',
|
|
'trait': 'round-diamond',
|
|
'protocol': 'round-diamond',
|
|
'enum': 'round-hexagon',
|
|
'component': 'round-pentagon',
|
|
'file': 'round-rectangle',
|
|
'module': 'round-rectangle',
|
|
'namespace': 'round-rectangle',
|
|
};
|
|
|
|
const edgeColors = {
|
|
'calls': '#58a6ff',
|
|
'imports': '#f0883e',
|
|
'extends': '#d2a8ff',
|
|
'implements': '#ffa657',
|
|
'references': '#8b949e',
|
|
'contains': '#3d444d',
|
|
'type_of': '#76e3ea',
|
|
'returns': '#76e3ea',
|
|
'instantiates': '#f778ba',
|
|
'overrides': '#d29922',
|
|
'decorates': '#f778ba',
|
|
'exports': '#3fb950',
|
|
};
|
|
|
|
// ====================================================================
|
|
// API Client
|
|
// ====================================================================
|
|
const api = {
|
|
async get(path) {
|
|
const res = await fetch('/api/' + path);
|
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
return res.json();
|
|
},
|
|
embeddingsStatus: () => api.get('embeddings/status'),
|
|
status: () => api.get('status'),
|
|
search: (q, kind, limit) => api.get(`search?q=${encodeURIComponent(q)}${kind ? '&kind='+kind : ''}&limit=${limit||30}`),
|
|
explore: (q) => api.get(`explore?q=${encodeURIComponent(q)}`),
|
|
overview: (limit) => api.get(`overview?limit=${limit||60}`),
|
|
files: () => api.get('files'),
|
|
fileNodes: (p) => api.get(`file-nodes?path=${encodeURIComponent(p)}`),
|
|
node: (id) => api.get(`node/${encodeURIComponent(id)}`),
|
|
callers: (id, d) => api.get(`node/${encodeURIComponent(id)}/callers?depth=${d||1}`),
|
|
callees: (id, d) => api.get(`node/${encodeURIComponent(id)}/callees?depth=${d||1}`),
|
|
children: (id) => api.get(`node/${encodeURIComponent(id)}/children`),
|
|
impact: (id, d) => api.get(`node/${encodeURIComponent(id)}/impact?depth=${d||2}`),
|
|
callgraph: (id, d) => api.get(`node/${encodeURIComponent(id)}/callgraph?depth=${d||2}`),
|
|
context: (id) => api.get(`node/${encodeURIComponent(id)}/context`),
|
|
};
|
|
|
|
// ====================================================================
|
|
// Cytoscape Initialization
|
|
// ====================================================================
|
|
function initCytoscape() {
|
|
cy = cytoscape({
|
|
container: document.getElementById('cy'),
|
|
style: [
|
|
// Nodes
|
|
{
|
|
selector: 'node',
|
|
style: {
|
|
'label': 'data(label)',
|
|
'text-valign': 'center',
|
|
'text-halign': 'center',
|
|
'font-size': '12px',
|
|
'font-weight': 'bold',
|
|
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
|
'color': '#ffffff',
|
|
'text-outline-color': '#000000',
|
|
'text-outline-width': 2,
|
|
'text-outline-opacity': 0.6,
|
|
'background-color': 'data(color)',
|
|
'background-opacity': 0.85,
|
|
'border-width': 2,
|
|
'border-color': 'data(color)',
|
|
'border-opacity': 0.7,
|
|
'width': 'label',
|
|
'height': 'label',
|
|
'padding': '12px',
|
|
'shape': 'data(shape)',
|
|
'text-wrap': 'wrap',
|
|
'text-max-width': '180px',
|
|
'transition-property': 'background-opacity, border-color, border-opacity, opacity, text-opacity',
|
|
'transition-duration': '0.2s',
|
|
}
|
|
},
|
|
// Selected node
|
|
{
|
|
selector: 'node:selected',
|
|
style: {
|
|
'border-width': 3,
|
|
'border-color': '#ffffff',
|
|
'border-opacity': 1,
|
|
'background-opacity': 1,
|
|
'z-index': 10,
|
|
}
|
|
},
|
|
// Hovered node
|
|
{
|
|
selector: 'node.hover',
|
|
style: {
|
|
'border-width': 3,
|
|
'border-color': '#ffffff',
|
|
'border-opacity': 0.9,
|
|
'background-opacity': 1,
|
|
}
|
|
},
|
|
// Faded node — keep text readable
|
|
{
|
|
selector: 'node.faded',
|
|
style: {
|
|
'background-opacity': 0.3,
|
|
'border-opacity': 0.2,
|
|
'text-opacity': 0.7,
|
|
}
|
|
},
|
|
// Highlighted node
|
|
{
|
|
selector: 'node.highlighted',
|
|
style: {
|
|
'border-width': 3,
|
|
'border-color': '#f0e68c',
|
|
'border-opacity': 1,
|
|
'z-index': 10,
|
|
}
|
|
},
|
|
// Edges
|
|
{
|
|
selector: 'edge',
|
|
style: {
|
|
'width': 1.5,
|
|
'line-color': 'data(color)',
|
|
'target-arrow-color': 'data(color)',
|
|
'target-arrow-shape': 'triangle',
|
|
'arrow-scale': 0.8,
|
|
'curve-style': 'bezier',
|
|
'opacity': 0.6,
|
|
'label': 'data(label)',
|
|
'font-size': '9px',
|
|
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
|
'color': '#656d76',
|
|
'text-rotation': 'autorotate',
|
|
'text-margin-y': -8,
|
|
'text-outline-color': '#0d1117',
|
|
'text-outline-width': 2,
|
|
'transition-property': 'opacity, line-color',
|
|
'transition-duration': '0.15s',
|
|
}
|
|
},
|
|
// Selected edge
|
|
{
|
|
selector: 'edge:selected',
|
|
style: { 'opacity': 1, 'width': 2.5 }
|
|
},
|
|
// Faded edge
|
|
{
|
|
selector: 'edge.faded',
|
|
style: { 'opacity': 0.15 }
|
|
},
|
|
// Highlighted edge
|
|
{
|
|
selector: 'edge.highlighted',
|
|
style: { 'opacity': 1, 'width': 2.5 }
|
|
},
|
|
],
|
|
layout: { name: 'preset' },
|
|
minZoom: 0.1,
|
|
maxZoom: 4,
|
|
wheelSensitivity: 0.3,
|
|
});
|
|
|
|
// Event handlers
|
|
cy.on('tap', 'node', (e) => {
|
|
const nodeId = e.target.data('nodeId');
|
|
if (nodeId) showNodeDetails(nodeId);
|
|
highlightNeighborhood(e.target);
|
|
});
|
|
|
|
cy.on('cxttap', 'node', (e) => {
|
|
e.originalEvent.preventDefault();
|
|
ctxNodeId = e.target.data('nodeId');
|
|
showContextMenu(e.originalEvent.clientX, e.originalEvent.clientY);
|
|
});
|
|
|
|
cy.on('tap', (e) => {
|
|
if (e.target === cy) {
|
|
clearHighlights();
|
|
hideContextMenu();
|
|
}
|
|
});
|
|
|
|
cy.on('mouseover', 'node', (e) => {
|
|
e.target.addClass('hover');
|
|
showTooltip(e);
|
|
});
|
|
|
|
cy.on('mouseout', 'node', (e) => {
|
|
e.target.removeClass('hover');
|
|
hideTooltip();
|
|
});
|
|
|
|
cy.on('dblclick', 'node', (e) => {
|
|
const nodeId = e.target.data('nodeId');
|
|
if (nodeId) expandCallees(nodeId);
|
|
});
|
|
|
|
// Click outside to close context menu
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('#context-menu')) hideContextMenu();
|
|
});
|
|
|
|
document.addEventListener('contextmenu', (e) => {
|
|
if (e.target.closest('#cy')) e.preventDefault();
|
|
});
|
|
}
|
|
|
|
// ====================================================================
|
|
// Graph Operations
|
|
// ====================================================================
|
|
const kindLabels = {
|
|
'function': 'fn', 'method': 'method', 'class': 'class', 'interface': 'iface',
|
|
'component': 'comp', 'route': 'route', 'enum': 'enum', 'type_alias': 'type',
|
|
'struct': 'struct', 'trait': 'trait', 'variable': 'var', 'constant': 'const',
|
|
'property': 'prop', 'field': 'field', 'file': 'file', 'module': 'mod',
|
|
};
|
|
|
|
function addNodeToGraph(node) {
|
|
if (cy.getElementById(node.id).length > 0) return;
|
|
const color = kindColors[node.kind] || '#8b949e';
|
|
const shape = kindShapes[node.kind] || 'round-rectangle';
|
|
const kindLabel = kindLabels[node.kind] || node.kind;
|
|
cy.add({
|
|
group: 'nodes',
|
|
data: {
|
|
id: node.id,
|
|
nodeId: node.id,
|
|
label: `${node.name}\n${kindLabel}`,
|
|
color: color,
|
|
shape: shape,
|
|
kind: node.kind,
|
|
filePath: node.filePath,
|
|
signature: node.signature || '',
|
|
},
|
|
});
|
|
}
|
|
|
|
function addEdgeToGraph(edge) {
|
|
const edgeId = `${edge.source}-${edge.kind}-${edge.target}`;
|
|
if (cy.getElementById(edgeId).length > 0) return;
|
|
// Don't add edge if source or target not in graph
|
|
if (cy.getElementById(edge.source).length === 0 || cy.getElementById(edge.target).length === 0) return;
|
|
const color = edgeColors[edge.kind] || '#8b949e';
|
|
cy.add({
|
|
group: 'edges',
|
|
data: {
|
|
id: edgeId,
|
|
source: edge.source,
|
|
target: edge.target,
|
|
kind: edge.kind,
|
|
label: edge.kind,
|
|
color: color,
|
|
},
|
|
});
|
|
}
|
|
|
|
function addSubgraph(nodes, edges) {
|
|
const batchElements = [];
|
|
for (const node of nodes) {
|
|
if (cy.getElementById(node.id).length > 0) continue;
|
|
const color = kindColors[node.kind] || '#8b949e';
|
|
const shape = kindShapes[node.kind] || 'round-rectangle';
|
|
batchElements.push({
|
|
group: 'nodes',
|
|
data: {
|
|
id: node.id,
|
|
nodeId: node.id,
|
|
label: node.name,
|
|
color: color,
|
|
shape: shape,
|
|
kind: node.kind,
|
|
filePath: node.filePath,
|
|
signature: node.signature || '',
|
|
},
|
|
});
|
|
}
|
|
for (const edge of edges) {
|
|
const edgeId = `${edge.source}-${edge.kind}-${edge.target}`;
|
|
if (cy.getElementById(edgeId).length > 0) continue;
|
|
// Check source/target will exist
|
|
const srcExists = cy.getElementById(edge.source).length > 0 || batchElements.some(e => e.data.id === edge.source);
|
|
const tgtExists = cy.getElementById(edge.target).length > 0 || batchElements.some(e => e.data.id === edge.target);
|
|
if (!srcExists || !tgtExists) continue;
|
|
const color = edgeColors[edge.kind] || '#8b949e';
|
|
batchElements.push({
|
|
group: 'edges',
|
|
data: {
|
|
id: edgeId,
|
|
source: edge.source,
|
|
target: edge.target,
|
|
kind: edge.kind,
|
|
label: edge.kind,
|
|
color: color,
|
|
},
|
|
});
|
|
}
|
|
if (batchElements.length > 0) {
|
|
cy.add(batchElements);
|
|
}
|
|
}
|
|
|
|
function clearGraph() {
|
|
cy.elements().remove();
|
|
expandedSets.callers.clear();
|
|
expandedSets.callees.clear();
|
|
hideOverlay(false);
|
|
closeDetailPanel();
|
|
}
|
|
|
|
function runLayout() {
|
|
if (cy.nodes().length === 0) return;
|
|
const layout = cy.layout({
|
|
name: 'dagre',
|
|
rankDir: 'LR',
|
|
nodeSep: 50,
|
|
rankSep: 80,
|
|
edgeSep: 20,
|
|
animate: true,
|
|
animationDuration: 300,
|
|
fit: true,
|
|
padding: 40,
|
|
});
|
|
layout.run();
|
|
}
|
|
|
|
function fitGraph() {
|
|
if (cy.nodes().length > 0) {
|
|
cy.animate({ fit: { eles: cy.elements(), padding: 40 } }, { duration: 300 });
|
|
}
|
|
}
|
|
|
|
function highlightNeighborhood(node) {
|
|
clearHighlights();
|
|
const neighborhood = node.closedNeighborhood();
|
|
cy.elements().not(neighborhood).addClass('faded');
|
|
neighborhood.edges().addClass('highlighted');
|
|
}
|
|
|
|
function clearHighlights() {
|
|
cy.elements().removeClass('faded highlighted');
|
|
}
|
|
|
|
function hideOverlay(hide = true) {
|
|
const overlay = document.getElementById('graph-overlay');
|
|
overlay.style.display = hide ? 'none' : 'block';
|
|
}
|
|
|
|
// ====================================================================
|
|
// Data Loading
|
|
// ====================================================================
|
|
async function loadOverview() {
|
|
showToast('Loading overview...');
|
|
try {
|
|
const data = await api.overview(60);
|
|
if (data.nodes.length === 0) {
|
|
showToast('No symbols found. Is the project indexed?');
|
|
return;
|
|
}
|
|
clearGraph();
|
|
hideOverlay();
|
|
for (const node of data.nodes) addNodeToGraph(node);
|
|
runLayout();
|
|
showToast(`Loaded ${data.nodes.length} symbols`);
|
|
} catch (err) {
|
|
showToast('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function expandCallers(nodeId) {
|
|
if (expandedSets.callers.has(nodeId)) return;
|
|
expandedSets.callers.add(nodeId);
|
|
try {
|
|
const data = await api.callers(nodeId, 1);
|
|
if (data.items.length === 0) {
|
|
showToast('No callers found');
|
|
return;
|
|
}
|
|
for (const item of data.items) {
|
|
addNodeToGraph(item.node);
|
|
addEdgeToGraph(item.edge);
|
|
}
|
|
runLayout();
|
|
showToast(`Found ${data.items.length} callers`);
|
|
} catch (err) {
|
|
showToast('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function expandCallees(nodeId) {
|
|
if (expandedSets.callees.has(nodeId)) return;
|
|
expandedSets.callees.add(nodeId);
|
|
try {
|
|
const data = await api.callees(nodeId, 1);
|
|
if (data.items.length === 0) {
|
|
showToast('No callees found');
|
|
return;
|
|
}
|
|
for (const item of data.items) {
|
|
addNodeToGraph(item.node);
|
|
addEdgeToGraph(item.edge);
|
|
}
|
|
runLayout();
|
|
showToast(`Found ${data.items.length} callees`);
|
|
} catch (err) {
|
|
showToast('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function loadCallGraph(nodeId) {
|
|
showToast('Loading call graph...');
|
|
try {
|
|
const data = await api.callgraph(nodeId, 2);
|
|
addSubgraph(data.nodes, data.edges);
|
|
runLayout();
|
|
showToast(`Loaded call graph: ${data.nodes.length} nodes`);
|
|
} catch (err) {
|
|
showToast('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function loadImpact(nodeId) {
|
|
showToast('Analyzing impact...');
|
|
try {
|
|
const data = await api.impact(nodeId, 2);
|
|
addSubgraph(data.nodes, data.edges);
|
|
runLayout();
|
|
// Highlight the root
|
|
const rootEle = cy.getElementById(nodeId);
|
|
if (rootEle.length > 0) {
|
|
rootEle.addClass('highlighted');
|
|
}
|
|
showToast(`Impact: ${data.nodes.length} nodes potentially affected`);
|
|
} catch (err) {
|
|
showToast('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function loadChildren(nodeId) {
|
|
try {
|
|
const data = await api.children(nodeId);
|
|
if (data.children.length === 0) {
|
|
showToast('No children found');
|
|
return;
|
|
}
|
|
for (const child of data.children) {
|
|
addNodeToGraph(child);
|
|
// Add contains edge
|
|
addEdgeToGraph({ source: nodeId, target: child.id, kind: 'contains' });
|
|
}
|
|
runLayout();
|
|
showToast(`Found ${data.children.length} children`);
|
|
} catch (err) {
|
|
showToast('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function loadFileNodes(filePath) {
|
|
showToast('Loading file symbols...');
|
|
try {
|
|
const data = await api.fileNodes(filePath);
|
|
if (data.nodes.length === 0) {
|
|
showToast('No symbols in this file');
|
|
return;
|
|
}
|
|
clearGraph();
|
|
hideOverlay();
|
|
for (const node of data.nodes) addNodeToGraph(node);
|
|
runLayout();
|
|
showToast(`Loaded ${data.nodes.length} symbols from file`);
|
|
} catch (err) {
|
|
showToast('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// ====================================================================
|
|
// Explore — natural language question → graph
|
|
// ====================================================================
|
|
async function exploreQuery(question) {
|
|
hideSearchDropdown();
|
|
clearGraph();
|
|
hideOverlay();
|
|
document.getElementById('search-input').value = question;
|
|
|
|
showToast('Finding entry point...');
|
|
|
|
try {
|
|
const data = await api.explore(question);
|
|
if (data.nodes.length === 0) {
|
|
showToast('No relevant code found. Try searching for a specific symbol.');
|
|
hideOverlay(false);
|
|
return;
|
|
}
|
|
addSubgraph(data.nodes, data.edges);
|
|
runLayout();
|
|
|
|
// Center on entry point
|
|
if (data.entryPoint) {
|
|
const entryEle = cy.getElementById(data.entryPoint);
|
|
if (entryEle.length > 0) {
|
|
entryEle.select();
|
|
entryEle.addClass('highlighted');
|
|
setTimeout(() => {
|
|
cy.animate({ center: { eles: entryEle } }, { duration: 400 });
|
|
showNodeDetails(data.entryPoint);
|
|
}, 350);
|
|
}
|
|
}
|
|
|
|
const source = data.usedClaude ? ' (via Claude)' : '';
|
|
showToast(`Traced ${data.nodes.length} symbols from entry point${source}`);
|
|
} catch (err) {
|
|
showToast('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// ====================================================================
|
|
// Search
|
|
// ====================================================================
|
|
function onSearchInput(e) {
|
|
const query = e.target.value.trim();
|
|
clearTimeout(searchDebounce);
|
|
if (!query) {
|
|
hideSearchDropdown();
|
|
return;
|
|
}
|
|
searchDebounce = setTimeout(async () => {
|
|
try {
|
|
const data = await api.search(query, null, 20);
|
|
showSearchResults(data.results);
|
|
} catch (err) {
|
|
console.error('Search error:', err);
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
function onSearchKeydown(e) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const query = e.target.value.trim();
|
|
if (!query) return;
|
|
|
|
// If dropdown is visible and has results, select the first one
|
|
const dropdown = document.getElementById('search-results-dropdown');
|
|
const firstItem = dropdown.querySelector('.search-result-item');
|
|
if (dropdown.classList.contains('visible') && firstItem) {
|
|
firstItem.click();
|
|
} else {
|
|
// Trigger a search and auto-select first result
|
|
(async () => {
|
|
try {
|
|
const data = await api.search(query, null, 10);
|
|
if (data.results.length > 0) {
|
|
selectSearchResult(data.results[0].node.id);
|
|
} else {
|
|
showToast('No symbols found. Try a different search.');
|
|
}
|
|
} catch (err) {
|
|
showToast('Search error: ' + err.message);
|
|
}
|
|
})();
|
|
}
|
|
}
|
|
}
|
|
|
|
function showSearchResults(results) {
|
|
const dropdown = document.getElementById('search-results-dropdown');
|
|
if (results.length === 0) {
|
|
dropdown.innerHTML = '<div style="padding:12px;color:var(--text-muted);font-size:13px;">No results found</div>';
|
|
dropdown.classList.add('visible');
|
|
return;
|
|
}
|
|
dropdown.innerHTML = results.map(r => `
|
|
<div class="search-result-item" onclick="selectSearchResult('${escapeAttr(r.node.id)}')">
|
|
<span class="kind-badge" style="background:${kindColors[r.node.kind] || '#8b949e'}22;color:${kindColors[r.node.kind] || '#8b949e'}">${r.node.kind}</span>
|
|
<span class="name">${escapeHtml(r.node.name)}</span>
|
|
<span class="file-path">${escapeHtml(r.node.filePath)}</span>
|
|
</div>
|
|
`).join('');
|
|
dropdown.classList.add('visible');
|
|
}
|
|
|
|
function hideSearchDropdown() {
|
|
document.getElementById('search-results-dropdown').classList.remove('visible');
|
|
}
|
|
|
|
async function selectSearchResult(nodeId) {
|
|
hideSearchDropdown();
|
|
document.getElementById('search-input').value = '';
|
|
hideOverlay();
|
|
clearGraph();
|
|
hideOverlay();
|
|
|
|
showToast('Tracing call chain...');
|
|
|
|
try {
|
|
// Load the call graph from this entry point (depth 3 forward)
|
|
const data = await api.callgraph(nodeId, 3);
|
|
if (data.nodes.length === 0) {
|
|
// Fallback: just show the node
|
|
const nodeData = await api.node(nodeId);
|
|
if (nodeData.node) addNodeToGraph(nodeData.node);
|
|
} else {
|
|
addSubgraph(data.nodes, data.edges);
|
|
}
|
|
|
|
runLayout();
|
|
|
|
// Select and center on the entry point
|
|
const ele = cy.getElementById(nodeId);
|
|
if (ele.length > 0) {
|
|
ele.select();
|
|
ele.addClass('highlighted');
|
|
setTimeout(() => {
|
|
cy.animate({ center: { eles: ele } }, { duration: 300 });
|
|
}, 350);
|
|
}
|
|
|
|
showNodeDetails(nodeId);
|
|
showToast(`Traced ${data.nodes.length} symbols from entry point`);
|
|
} catch (err) {
|
|
showToast('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// ====================================================================
|
|
// Detail Panel
|
|
// ====================================================================
|
|
async function showNodeDetails(nodeId) {
|
|
const panel = document.getElementById('detail-panel');
|
|
const body = document.getElementById('detail-body');
|
|
const title = document.getElementById('detail-title');
|
|
|
|
panel.classList.add('open');
|
|
|
|
body.innerHTML = '<div style="padding:20px;text-align:center;"><div class="spinner"></div></div>';
|
|
|
|
try {
|
|
const [nodeData, contextData] = await Promise.all([
|
|
api.node(nodeId),
|
|
api.context(nodeId),
|
|
]);
|
|
|
|
const node = nodeData.node;
|
|
const code = nodeData.code;
|
|
const ancestors = nodeData.ancestors || [];
|
|
const ctx = contextData.context;
|
|
|
|
title.textContent = node.name;
|
|
|
|
let html = '';
|
|
|
|
// Quick actions
|
|
html += `<div class="detail-section" style="padding:8px 16px;">
|
|
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
|
<button class="toolbar-btn" onclick="expandCallees('${escapeAttr(node.id)}')" style="font-size:12px;">Expand Callees →</button>
|
|
<button class="toolbar-btn" onclick="expandCallers('${escapeAttr(node.id)}')" style="font-size:12px;">← Expand Callers</button>
|
|
<button class="toolbar-btn" onclick="loadCallGraph('${escapeAttr(node.id)}')" style="font-size:12px;">Full Call Graph</button>
|
|
<button class="toolbar-btn" onclick="loadImpact('${escapeAttr(node.id)}')" style="font-size:12px;">Impact Analysis</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
// Meta info
|
|
html += `<div class="detail-section">
|
|
<div class="detail-section-title">Info</div>
|
|
<div class="detail-meta">
|
|
<span class="label">Kind</span>
|
|
<span class="value"><span class="kind-badge" style="background:${kindColors[node.kind] || '#8b949e'}22;color:${kindColors[node.kind] || '#8b949e'};font-size:10px;padding:1px 5px;border-radius:3px;">${node.kind}</span></span>
|
|
<span class="label">File</span>
|
|
<span class="value accent">${escapeHtml(node.filePath)}</span>
|
|
<span class="label">Lines</span>
|
|
<span class="value">${node.startLine} - ${node.endLine}</span>
|
|
${node.signature ? `<span class="label">Signature</span><span class="value" style="font-family:var(--font-mono);font-size:11px;">${escapeHtml(node.signature)}</span>` : ''}
|
|
${node.visibility ? `<span class="label">Visibility</span><span class="value">${node.visibility}</span>` : ''}
|
|
${node.isExported ? `<span class="label">Exported</span><span class="value">Yes</span>` : ''}
|
|
${node.isAsync ? `<span class="label">Async</span><span class="value">Yes</span>` : ''}
|
|
${node.decorators && node.decorators.length ? `<span class="label">Decorators</span><span class="value">${escapeHtml(node.decorators.join(', '))}</span>` : ''}
|
|
</div>
|
|
</div>`;
|
|
|
|
// Breadcrumb ancestors
|
|
if (ancestors.length > 0) {
|
|
html += `<div class="detail-section">
|
|
<div class="detail-section-title">Hierarchy</div>
|
|
<div style="font-size:12px;color:var(--text-secondary);">
|
|
${ancestors.map(a => `<span class="relation-item" onclick="selectSearchResult('${escapeAttr(a.id)}')" style="display:inline;cursor:pointer;color:var(--accent);">${escapeHtml(a.name)}</span>`).join(' <span style="color:var(--text-muted);">›</span> ')} <span style="color:var(--text-muted);">›</span> <strong>${escapeHtml(node.name)}</strong>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Source code
|
|
if (code) {
|
|
const lang = langForHighlight(node.language);
|
|
html += `<div class="detail-section">
|
|
<div class="detail-section-title">Source Code</div>
|
|
<div class="code-block"><pre><code class="language-${lang}">${escapeHtml(code)}</code></pre></div>
|
|
</div>`;
|
|
}
|
|
|
|
// Callers
|
|
if (ctx.incomingRefs && ctx.incomingRefs.length > 0) {
|
|
html += `<div class="detail-section">
|
|
<div class="detail-section-title">Called By (${ctx.incomingRefs.length})</div>
|
|
<ul class="relation-list">
|
|
${ctx.incomingRefs.slice(0, 20).map(r => `
|
|
<li class="relation-item" onclick="selectSearchResult('${escapeAttr(r.node.id)}')">
|
|
<span class="rel-badge" style="background:${kindColors[r.node.kind] || '#8b949e'}22;color:${kindColors[r.node.kind] || '#8b949e'}">${r.node.kind}</span>
|
|
${escapeHtml(r.node.name)}
|
|
<span style="margin-left:auto;color:var(--text-muted);font-size:11px;">${r.edge.kind}</span>
|
|
</li>
|
|
`).join('')}
|
|
</ul>
|
|
</div>`;
|
|
}
|
|
|
|
// Callees
|
|
if (ctx.outgoingRefs && ctx.outgoingRefs.length > 0) {
|
|
html += `<div class="detail-section">
|
|
<div class="detail-section-title">Calls (${ctx.outgoingRefs.length})</div>
|
|
<ul class="relation-list">
|
|
${ctx.outgoingRefs.slice(0, 20).map(r => `
|
|
<li class="relation-item" onclick="selectSearchResult('${escapeAttr(r.node.id)}')">
|
|
<span class="rel-badge" style="background:${kindColors[r.node.kind] || '#8b949e'}22;color:${kindColors[r.node.kind] || '#8b949e'}">${r.node.kind}</span>
|
|
${escapeHtml(r.node.name)}
|
|
<span style="margin-left:auto;color:var(--text-muted);font-size:11px;">${r.edge.kind}</span>
|
|
</li>
|
|
`).join('')}
|
|
</ul>
|
|
</div>`;
|
|
}
|
|
|
|
// Children
|
|
if (ctx.children && ctx.children.length > 0) {
|
|
html += `<div class="detail-section">
|
|
<div class="detail-section-title">Contains (${ctx.children.length})</div>
|
|
<ul class="relation-list">
|
|
${ctx.children.slice(0, 30).map(c => `
|
|
<li class="relation-item" onclick="selectSearchResult('${escapeAttr(c.id)}')">
|
|
<span class="rel-badge" style="background:${kindColors[c.kind] || '#8b949e'}22;color:${kindColors[c.kind] || '#8b949e'}">${c.kind}</span>
|
|
${escapeHtml(c.name)}
|
|
</li>
|
|
`).join('')}
|
|
</ul>
|
|
</div>`;
|
|
}
|
|
|
|
// Docstring
|
|
if (node.docstring) {
|
|
html += `<div class="detail-section">
|
|
<div class="detail-section-title">Documentation</div>
|
|
<div style="font-size:12px;color:var(--text-secondary);white-space:pre-wrap;font-family:var(--font-mono);line-height:1.5;">${escapeHtml(node.docstring)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
body.innerHTML = html;
|
|
|
|
// Apply syntax highlighting
|
|
body.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
|
|
|
|
} catch (err) {
|
|
body.innerHTML = `<div style="padding:20px;color:var(--red);">Error loading details: ${escapeHtml(err.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function closeDetailPanel() {
|
|
document.getElementById('detail-panel').classList.remove('open');
|
|
}
|
|
|
|
// ====================================================================
|
|
// Context Menu
|
|
// ====================================================================
|
|
function showContextMenu(x, y) {
|
|
const menu = document.getElementById('context-menu');
|
|
menu.style.left = x + 'px';
|
|
menu.style.top = y + 'px';
|
|
menu.classList.add('visible');
|
|
}
|
|
|
|
function hideContextMenu() {
|
|
document.getElementById('context-menu').classList.remove('visible');
|
|
}
|
|
|
|
function ctxAction(action) {
|
|
hideContextMenu();
|
|
if (!ctxNodeId) return;
|
|
switch (action) {
|
|
case 'expand-callees': expandCallees(ctxNodeId); break;
|
|
case 'expand-callers': expandCallers(ctxNodeId); break;
|
|
case 'callgraph': loadCallGraph(ctxNodeId); break;
|
|
case 'impact': loadImpact(ctxNodeId); break;
|
|
case 'children': loadChildren(ctxNodeId); break;
|
|
case 'details': showNodeDetails(ctxNodeId); break;
|
|
case 'remove':
|
|
cy.getElementById(ctxNodeId).remove();
|
|
expandedSets.callers.delete(ctxNodeId);
|
|
expandedSets.callees.delete(ctxNodeId);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ====================================================================
|
|
// Tooltip
|
|
// ====================================================================
|
|
function showTooltip(e) {
|
|
const node = e.target;
|
|
const tip = document.getElementById('tooltip');
|
|
const pos = e.originalEvent;
|
|
tip.innerHTML = `
|
|
<div class="tip-kind">${node.data('kind')}</div>
|
|
<div class="tip-name">${escapeHtml(node.data('label'))}</div>
|
|
${node.data('signature') ? `<div class="tip-file" style="font-family:var(--font-mono);">${escapeHtml(node.data('signature'))}</div>` : ''}
|
|
<div class="tip-file">${escapeHtml(node.data('filePath') || '')}</div>
|
|
`;
|
|
tip.style.left = (pos.clientX + 12) + 'px';
|
|
tip.style.top = (pos.clientY + 12) + 'px';
|
|
tip.style.display = 'block';
|
|
}
|
|
|
|
function hideTooltip() {
|
|
document.getElementById('tooltip').style.display = 'none';
|
|
}
|
|
|
|
// ====================================================================
|
|
// Toast notifications
|
|
// ====================================================================
|
|
function showToast(msg) {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = msg;
|
|
toast.classList.add('visible');
|
|
clearTimeout(toast._timeout);
|
|
toast._timeout = setTimeout(() => toast.classList.remove('visible'), 2500);
|
|
}
|
|
|
|
// ====================================================================
|
|
// Sidebar
|
|
// ====================================================================
|
|
function toggleSection(header) {
|
|
const content = header.nextElementSibling;
|
|
const arrow = header.querySelector('span:last-child');
|
|
if (content.style.display === 'none') {
|
|
content.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
content.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
}
|
|
|
|
async function loadFileTree() {
|
|
try {
|
|
const data = await api.files();
|
|
const tree = document.getElementById('file-tree');
|
|
if (data.files.length === 0) {
|
|
tree.innerHTML = '<div style="padding:12px;color:var(--text-muted);font-size:12px;">No files indexed</div>';
|
|
return;
|
|
}
|
|
// Group by directory
|
|
const dirs = {};
|
|
for (const f of data.files) {
|
|
const dir = f.filePath.split('/').slice(0, -1).join('/') || '.';
|
|
if (!dirs[dir]) dirs[dir] = [];
|
|
dirs[dir].push(f);
|
|
}
|
|
let html = '';
|
|
const sortedDirs = Object.keys(dirs).sort();
|
|
for (const dir of sortedDirs) {
|
|
html += `<div class="file-item" style="color:var(--text-muted);font-weight:500;padding-top:8px;" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'">
|
|
<span class="file-icon">📁</span> ${escapeHtml(dir)}/
|
|
</div><div>`;
|
|
for (const f of dirs[dir].sort((a, b) => a.filePath.localeCompare(b.filePath))) {
|
|
const fileName = f.filePath.split('/').pop();
|
|
html += `<div class="file-item" style="padding-left:28px;" onclick="loadFileNodes('${escapeAttr(f.filePath)}')">
|
|
<span class="file-icon">📄</span> ${escapeHtml(fileName)}
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
tree.innerHTML = html;
|
|
} catch (err) {
|
|
console.error('Failed to load file tree:', err);
|
|
}
|
|
}
|
|
|
|
function buildLegend() {
|
|
const legendEl = document.getElementById('legend');
|
|
const kinds = ['function', 'method', 'class', 'interface', 'component', 'enum', 'variable', 'constant', 'property', 'type_alias', 'import', 'export'];
|
|
legendEl.innerHTML = kinds.map(k => `
|
|
<div class="legend-item">
|
|
<div class="legend-dot" style="background:${kindColors[k]};"></div>
|
|
<span>${k.replace('_', ' ')}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// ====================================================================
|
|
// Helpers
|
|
// ====================================================================
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
function escapeAttr(str) {
|
|
if (!str) return '';
|
|
return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
}
|
|
|
|
function langForHighlight(lang) {
|
|
const map = {
|
|
'typescript': 'typescript', 'javascript': 'javascript', 'tsx': 'typescript',
|
|
'jsx': 'javascript', 'python': 'python', 'go': 'go', 'rust': 'rust',
|
|
'java': 'java', 'c': 'c', 'cpp': 'cpp', 'csharp': 'csharp',
|
|
'php': 'php', 'ruby': 'ruby', 'swift': 'swift', 'kotlin': 'kotlin',
|
|
'dart': 'dart', 'svelte': 'xml', 'liquid': 'xml', 'pascal': 'delphi',
|
|
};
|
|
return map[lang] || 'plaintext';
|
|
}
|
|
|
|
// ====================================================================
|
|
// Keyboard Shortcuts
|
|
// ====================================================================
|
|
document.addEventListener('keydown', (e) => {
|
|
// Ctrl+K or Cmd+K → focus search
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
document.getElementById('search-input').focus();
|
|
}
|
|
// Escape → close things
|
|
if (e.key === 'Escape') {
|
|
hideSearchDropdown();
|
|
hideContextMenu();
|
|
closeDetailPanel();
|
|
clearHighlights();
|
|
document.getElementById('search-input').blur();
|
|
}
|
|
// Delete/Backspace → remove selected nodes
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && document.activeElement.tagName !== 'INPUT') {
|
|
const selected = cy.$(':selected');
|
|
if (selected.length > 0) {
|
|
selected.remove();
|
|
}
|
|
}
|
|
});
|
|
|
|
// ====================================================================
|
|
// Embeddings Setup Dialog
|
|
// ====================================================================
|
|
function showDialog() {
|
|
document.getElementById('dialog-overlay').classList.add('visible');
|
|
}
|
|
|
|
function closeDialog() {
|
|
document.getElementById('dialog-overlay').classList.remove('visible');
|
|
}
|
|
|
|
async function checkEmbeddings() {
|
|
try {
|
|
const data = await api.embeddingsStatus();
|
|
if (!data.isReady) {
|
|
showDialog();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to check embeddings status:', err);
|
|
}
|
|
}
|
|
|
|
function startEmbeddings() {
|
|
const btnEnable = document.getElementById('dialog-enable');
|
|
const btnSkip = document.getElementById('dialog-skip');
|
|
const progress = document.getElementById('dialog-progress');
|
|
const progressFill = document.getElementById('dialog-progress-fill');
|
|
const progressText = document.getElementById('dialog-progress-text');
|
|
const progressPercent = document.getElementById('dialog-progress-percent');
|
|
const title = document.getElementById('dialog-title');
|
|
const body = document.getElementById('dialog-body');
|
|
|
|
// Update UI to progress mode
|
|
btnEnable.disabled = true;
|
|
btnEnable.textContent = 'Setting up...';
|
|
btnSkip.style.display = 'none';
|
|
progress.style.display = 'block';
|
|
body.innerHTML = 'Setting up semantic search for your project. This only needs to happen once.';
|
|
|
|
const evtSource = new EventSource('/api/embeddings/generate');
|
|
|
|
evtSource.addEventListener('status', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
progressText.textContent = data.message;
|
|
title.textContent = data.phase === 'model' ? 'Downloading Model...' :
|
|
data.phase === 'embedding' ? 'Generating Embeddings...' :
|
|
'Setting Up...';
|
|
});
|
|
|
|
evtSource.addEventListener('progress', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
progressFill.style.width = data.percent + '%';
|
|
progressPercent.textContent = data.percent + '%';
|
|
progressText.textContent = data.nodeName
|
|
? `Embedding: ${data.nodeName} (${data.current}/${data.total})`
|
|
: `Processing ${data.current} of ${data.total}...`;
|
|
});
|
|
|
|
evtSource.addEventListener('complete', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
evtSource.close();
|
|
title.textContent = 'Ready!';
|
|
body.innerHTML = `<strong>${data.message}</strong><br><br>Semantic search is now active. Your explore queries will understand code meaning, not just keywords.`;
|
|
progressFill.style.width = '100%';
|
|
progressPercent.textContent = '100%';
|
|
progressText.textContent = 'Complete';
|
|
|
|
// Change actions to just a close button
|
|
document.getElementById('dialog-actions').innerHTML =
|
|
'<button class="btn btn-primary" onclick="closeDialog()">Start Exploring</button>';
|
|
});
|
|
|
|
evtSource.addEventListener('error', (e) => {
|
|
let msg = 'An error occurred during setup.';
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
msg = data.message || msg;
|
|
} catch {}
|
|
evtSource.close();
|
|
title.textContent = 'Setup Error';
|
|
body.innerHTML = `<span style="color:var(--red);">${escapeHtml(msg)}</span><br><br>You can still use the explorer with keyword-based search.`;
|
|
document.getElementById('dialog-actions').innerHTML =
|
|
'<button class="btn btn-secondary" onclick="closeDialog()">Close</button>';
|
|
});
|
|
|
|
// SSE connection error (different from app error event)
|
|
evtSource.onerror = () => {
|
|
// EventSource reconnects automatically; if it closes, we handle via the error event above
|
|
};
|
|
}
|
|
|
|
// ====================================================================
|
|
// Init
|
|
// ====================================================================
|
|
async function init() {
|
|
initCytoscape();
|
|
buildLegend();
|
|
|
|
// Load stats
|
|
try {
|
|
const data = await api.status();
|
|
document.getElementById('stat-nodes').textContent = data.stats.nodeCount.toLocaleString();
|
|
document.getElementById('stat-edges').textContent = data.stats.edgeCount.toLocaleString();
|
|
document.getElementById('stat-files').textContent = data.stats.fileCount.toLocaleString();
|
|
document.title = `CodeGraph - ${data.projectName}`;
|
|
} catch (err) {
|
|
console.error('Failed to load status:', err);
|
|
}
|
|
|
|
// Load file tree
|
|
loadFileTree();
|
|
|
|
// Embeddings dialog available but not auto-shown
|
|
// (local embedding model quality is insufficient for natural language queries)
|
|
|
|
// Search input
|
|
document.getElementById('search-input').addEventListener('input', onSearchInput);
|
|
document.getElementById('search-input').addEventListener('keydown', onSearchKeydown);
|
|
document.getElementById('search-input').addEventListener('focus', () => {
|
|
if (document.getElementById('search-input').value.trim()) {
|
|
document.getElementById('search-results-dropdown').classList.add('visible');
|
|
}
|
|
});
|
|
|
|
// Click outside search dropdown to close
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('#search-container')) hideSearchDropdown();
|
|
});
|
|
}
|
|
|
|
// Start
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|