|
|
@@ -0,0 +1,1960 @@
|
|
|
+<!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: 420px;
|
|
|
+ --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="Ask about your code... (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">Ask about your code</div>
|
|
|
+ <div class="overlay-subtitle">Try: "How does authentication work?" or "What happens when a user signs in?"</div>
|
|
|
+ <div class="overlay-examples" style="margin-top:16px; display:flex; flex-wrap:wrap; gap:8px; justify-content:center;">
|
|
|
+ <button class="example-btn" onclick="exploreQuery('authentication and login')">Authentication flow</button>
|
|
|
+ <button class="example-btn" onclick="exploreQuery('API routes and endpoints')">API routes</button>
|
|
|
+ <button class="example-btn" onclick="exploreQuery('database and data storage')">Database layer</button>
|
|
|
+ <button class="example-btn" onclick="exploreQuery('error handling')">Error handling</button>
|
|
|
+ </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, maxNodes) => api.get(`explore?q=${encodeURIComponent(q)}&maxNodes=${maxNodes||30}`),
|
|
|
+ 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': 'none',
|
|
|
+ 'text-max-width': '160px',
|
|
|
+ '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
|
|
|
+ // ====================================================================
|
|
|
+ function addNodeToGraph(node) {
|
|
|
+ if (cy.getElementById(node.id).length > 0) return;
|
|
|
+ const color = kindColors[node.kind] || '#8b949e';
|
|
|
+ const shape = kindShapes[node.kind] || 'round-rectangle';
|
|
|
+ cy.add({
|
|
|
+ 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 || '',
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ 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('Asking Claude...');
|
|
|
+
|
|
|
+ try {
|
|
|
+ const data = await api.explore(question, 30);
|
|
|
+ if (data.nodes.length === 0) {
|
|
|
+ showToast('No relevant code found. Try different keywords.');
|
|
|
+ hideOverlay(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ addSubgraph(data.nodes, data.edges);
|
|
|
+
|
|
|
+ // Highlight root/entry-point nodes
|
|
|
+ if (data.roots && data.roots.length > 0) {
|
|
|
+ for (const rootId of data.roots) {
|
|
|
+ const ele = cy.getElementById(rootId);
|
|
|
+ if (ele.length > 0) ele.addClass('highlighted');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ runLayout();
|
|
|
+ const source = data.usedClaude ? ' (via Claude)' : '';
|
|
|
+ showToast(`Found ${data.nodes.length} related symbols${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) {
|
|
|
+ hideSearchDropdown();
|
|
|
+ exploreQuery(query);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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();
|
|
|
+
|
|
|
+ // Add to graph if not present
|
|
|
+ try {
|
|
|
+ const data = await api.node(nodeId);
|
|
|
+ if (data.node) {
|
|
|
+ addNodeToGraph(data.node);
|
|
|
+ // Also load its immediate relations
|
|
|
+ const [callersData, calleesData] = await Promise.all([
|
|
|
+ api.callers(nodeId, 1),
|
|
|
+ api.callees(nodeId, 1),
|
|
|
+ ]);
|
|
|
+ for (const item of callersData.items) {
|
|
|
+ addNodeToGraph(item.node);
|
|
|
+ addEdgeToGraph(item.edge);
|
|
|
+ }
|
|
|
+ for (const item of calleesData.items) {
|
|
|
+ addNodeToGraph(item.node);
|
|
|
+ addEdgeToGraph(item.edge);
|
|
|
+ }
|
|
|
+ expandedSets.callers.add(nodeId);
|
|
|
+ expandedSets.callees.add(nodeId);
|
|
|
+ runLayout();
|
|
|
+ // Select and focus
|
|
|
+ const ele = cy.getElementById(nodeId);
|
|
|
+ if (ele.length > 0) {
|
|
|
+ cy.nodes().unselect();
|
|
|
+ ele.select();
|
|
|
+ cy.animate({ center: { eles: ele }, zoom: 1.5 }, { duration: 300 });
|
|
|
+ }
|
|
|
+ showNodeDetails(nodeId);
|
|
|
+ }
|
|
|
+ } 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 = '';
|
|
|
+
|
|
|
+ // 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>
|