Archived
65e74d6474
Round 14: Breadcrumb directory click — clicking a path segment in the breadcrumb expands/reveals that directory in the file tree Round 15: El version in status bar — GET /api/status now returns el_version (via el --version), shown in status bar right side; EL_BINARY config env var Round 16: Search improvements — case-sensitive, whole-word, regex toggles (Alt+C/W/R); project-wide replace-all in current file; backend SearchOpts struct for each mode Round 17: Sticky scroll improvements — uses CM6 posAtCoords for accurate first-visible-line; clickable to jump to definition; sticky-name/sticky-goto styling Round 18: File tree header — New File (+) button and Refresh (↺) button in file tree header panel Round 19: Status bar diagnostics — error count (✕ N) and warning count (⚠ N) shown in status bar, clickable to jump to problems panel Round 20: Polish — more El snippets (test, seed, assert, activate, parallel, deploy, import, with, retry, reason, trace), expanded command palette (11 new commands)
5482 lines
201 KiB
HTML
5482 lines
201 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>el-ide — Engram Language IDE</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||
<style>
|
||
/* ── THEME VARIABLES ────────────────────────────────────────────────────────── */
|
||
:root {
|
||
--bg: #080b0f;
|
||
--bg2: #0d1117;
|
||
--bg3: #111820;
|
||
--bg4: #161e28;
|
||
--border: rgba(255,255,255,0.06);
|
||
--border2: rgba(255,255,255,0.12);
|
||
--text: #e8edf3;
|
||
--text2: #7a8a9a;
|
||
--text3: #4a5a6a;
|
||
--accent: #38bdf8;
|
||
--accent-dim: rgba(56,189,248,0.12);
|
||
--accent-glow: rgba(56,189,248,0.4);
|
||
--purple: #a78bfa;
|
||
--green: #4ade80;
|
||
--orange: #fb923c;
|
||
--red: #f87171;
|
||
--yellow: #fbbf24;
|
||
--radius: 4px;
|
||
--file-w: 220px;
|
||
--graph-w: 280px;
|
||
--bottom-h: 200px;
|
||
--header-h: 44px;
|
||
}
|
||
|
||
html.theme-light {
|
||
--bg: #f4f6f9;
|
||
--bg2: #ffffff;
|
||
--bg3: #f0f2f5;
|
||
--bg4: #e8ecf0;
|
||
--border: rgba(0,0,0,0.07);
|
||
--border2: rgba(0,0,0,0.13);
|
||
--text: #1a2233;
|
||
--text2: #3d4f62;
|
||
--text3: #8896a4;
|
||
--accent: #0077cc;
|
||
--accent-dim: rgba(0,119,204,0.10);
|
||
--accent-glow: rgba(0,119,204,0.25);
|
||
--purple: #6d28d9;
|
||
--green: #15803d;
|
||
--orange: #c2410c;
|
||
--red: #b91c1c;
|
||
--yellow: #b45309;
|
||
}
|
||
|
||
/* Light theme refinements */
|
||
html.theme-light body { background: var(--bg); }
|
||
html.theme-light #header {
|
||
background: #ffffff;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 0 rgba(0,0,0,0.04);
|
||
border-bottom: none;
|
||
}
|
||
html.theme-light .cm-gutters { color: #94a3b8 !important; }
|
||
html.theme-light .cm-activeLine { background: rgba(0,119,204,0.04) !important; }
|
||
html.theme-light .cm-selectionBackground { background: rgba(0,119,204,0.15) !important; }
|
||
html.theme-light .cm-matchingBracket { background: rgba(0,119,204,0.12) !important; }
|
||
html.theme-light #bottom-panel {
|
||
box-shadow: 0 -1px 3px rgba(0,0,0,0.05);
|
||
}
|
||
html.theme-light #status-bar { background: var(--bg2); }
|
||
html.theme-light #status-server { color: #15803d; }
|
||
html.theme-light ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); }
|
||
html.theme-light ::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.22); }
|
||
html.theme-light .cm-tooltip { background: #ffffff !important; box-shadow: 0 4px 16px rgba(0,0,0,0.12) !important; }
|
||
html.theme-light .cm-tooltip-autocomplete li { color: #1a2233 !important; }
|
||
html.theme-light .cm-tooltip-autocomplete li[aria-selected] { background: rgba(0,119,204,0.10) !important; color: #0077cc !important; }
|
||
|
||
html.theme-neuron {
|
||
--bg: #060810;
|
||
--bg2: #09091a;
|
||
--bg3: #0c0d1e;
|
||
--bg4: #100f24;
|
||
--border: rgba(139,92,246,0.14);
|
||
--border2: rgba(139,92,246,0.28);
|
||
--text: #e4dfff;
|
||
--text2: #9b8ed8;
|
||
--text3: #52448a;
|
||
--accent: #8b5cf6;
|
||
--accent-dim: rgba(139,92,246,0.18);
|
||
--accent-glow: rgba(139,92,246,0.55);
|
||
--purple: #c084fc;
|
||
--green: #34d399;
|
||
--orange: #fb923c;
|
||
--red: #f87171;
|
||
--yellow: #fbbf24;
|
||
}
|
||
|
||
/* Neuron theme — ambient glow effects */
|
||
html.theme-neuron #header {
|
||
background: linear-gradient(135deg, #09091a 0%, #0d0820 100%);
|
||
box-shadow: 0 1px 0 rgba(139,92,246,0.2), 0 4px 20px rgba(139,92,246,0.04);
|
||
}
|
||
html.theme-neuron #file-tree { box-shadow: inset -1px 0 0 rgba(139,92,246,0.1); }
|
||
html.theme-neuron .logo-dot {
|
||
background: var(--accent);
|
||
box-shadow: 0 0 12px var(--accent), 0 0 24px rgba(139,92,246,0.4), 0 0 40px rgba(139,92,246,0.2);
|
||
}
|
||
html.theme-neuron .btn-primary {
|
||
box-shadow: 0 0 8px rgba(139,92,246,0.3);
|
||
}
|
||
html.theme-neuron .btn-primary:hover {
|
||
box-shadow: 0 0 14px rgba(139,92,246,0.5), 0 0 28px rgba(139,92,246,0.2);
|
||
}
|
||
html.theme-neuron .editor-tab.active {
|
||
box-shadow: 0 -1px 8px rgba(139,92,246,0.15);
|
||
}
|
||
html.theme-neuron .cm-activeLine { background: rgba(139,92,246,0.06) !important; }
|
||
html.theme-neuron .cm-selectionBackground { background: rgba(139,92,246,0.2) !important; }
|
||
html.theme-neuron .cm-matchingBracket { background: rgba(139,92,246,0.18) !important; }
|
||
html.theme-neuron #status-bar {
|
||
background: linear-gradient(90deg, #09091a 0%, #0c0820 100%);
|
||
}
|
||
html.theme-neuron #bottom-panel {
|
||
box-shadow: 0 -1px 0 rgba(139,92,246,0.15);
|
||
}
|
||
@keyframes neuron-pulse {
|
||
0%,100% { opacity: 0.4; }
|
||
50% { opacity: 0.8; }
|
||
}
|
||
html.theme-neuron .logo-dot {
|
||
animation: pulse-dot 2s ease-in-out infinite, neuron-pulse 4s ease-in-out infinite;
|
||
}
|
||
|
||
html.theme-high-contrast {
|
||
--bg: #000000;
|
||
--bg2: #0a0a0a;
|
||
--bg3: #111111;
|
||
--bg4: #1a1a1a;
|
||
--border: rgba(255,255,255,0.25);
|
||
--border2: rgba(255,255,255,0.45);
|
||
--text: #ffffff;
|
||
--text2: #cccccc;
|
||
--text3: #888888;
|
||
--accent: #ffffff;
|
||
--accent-dim: rgba(255,255,255,0.1);
|
||
--accent-glow: rgba(255,255,255,0.3);
|
||
--purple: #dd88ff;
|
||
--green: #44ff88;
|
||
--orange: #ffaa44;
|
||
--red: #ff4444;
|
||
--yellow: #ffee44;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: 'Syne', sans-serif;
|
||
font-size: 13px;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* ── HEADER ─────────────────────────────────────────────────────────────────── */
|
||
#header {
|
||
height: var(--header-h);
|
||
min-height: var(--header-h);
|
||
background: var(--bg2);
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 14px;
|
||
gap: 10px;
|
||
z-index: 100;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.logo { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||
|
||
.logo-mark {
|
||
font-family: 'Syne', sans-serif;
|
||
font-weight: 800;
|
||
font-size: 15px;
|
||
letter-spacing: -0.03em;
|
||
color: var(--text);
|
||
}
|
||
|
||
.logo-mark span { color: var(--accent); }
|
||
|
||
.logo-dot {
|
||
width: 6px; height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
box-shadow: 0 0 10px var(--accent-glow);
|
||
animation: pulse-dot 2.5s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse-dot {
|
||
0%,100% { box-shadow: 0 0 6px var(--accent-glow); }
|
||
50% { box-shadow: 0 0 18px var(--accent-glow), 0 0 32px rgba(56,189,248,0.15); }
|
||
}
|
||
|
||
.header-sep { width: 1px; height: 22px; background: var(--border2); flex-shrink: 0; }
|
||
.header-spacer { flex: 1; }
|
||
|
||
.project-name {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 12px;
|
||
color: var(--text2);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.project-name span { color: var(--text); }
|
||
|
||
.target-select {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
padding: 4px 8px;
|
||
cursor: pointer;
|
||
outline: none;
|
||
}
|
||
|
||
.target-select:focus { border-color: var(--accent); }
|
||
|
||
.btn {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text);
|
||
font-family: 'Syne', sans-serif;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
padding: 5px 12px;
|
||
cursor: pointer;
|
||
letter-spacing: 0.02em;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.btn:hover { background: var(--bg4); border-color: var(--accent); color: var(--accent); }
|
||
|
||
.btn-primary {
|
||
background: var(--accent-dim);
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: var(--accent-dim);
|
||
box-shadow: 0 0 12px var(--accent-glow);
|
||
}
|
||
|
||
.btn-run {
|
||
background: rgba(74,222,128,0.08);
|
||
border-color: rgba(74,222,128,0.4);
|
||
color: var(--green);
|
||
}
|
||
|
||
.btn-run:hover { background: rgba(74,222,128,0.15); }
|
||
|
||
/* Theme dropdown */
|
||
.theme-dropdown { position: relative; }
|
||
.theme-menu {
|
||
position: absolute;
|
||
top: calc(100% + 6px);
|
||
right: 0;
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||
z-index: 500;
|
||
min-width: 140px;
|
||
overflow: hidden;
|
||
}
|
||
.theme-option {
|
||
padding: 7px 14px;
|
||
font-size: 12px;
|
||
color: var(--text2);
|
||
cursor: pointer;
|
||
transition: background 0.1s;
|
||
}
|
||
.theme-option:hover { background: var(--bg3); color: var(--text); }
|
||
.theme-option.active { color: var(--accent); background: var(--accent-dim); }
|
||
|
||
/* ── BREADCRUMB ─────────────────────────────────────────────────────────────── */
|
||
#breadcrumb {
|
||
height: 24px;
|
||
min-height: 24px;
|
||
background: var(--bg2);
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 14px;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
color: var(--text3);
|
||
gap: 4px;
|
||
flex-shrink: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.crumb {
|
||
color: var(--text2);
|
||
cursor: pointer;
|
||
transition: color 0.1s;
|
||
}
|
||
.crumb:hover { color: var(--accent); }
|
||
.crumb-sep { color: var(--text3); }
|
||
.crumb-current { color: var(--text); }
|
||
|
||
/* ── BODY ───────────────────────────────────────────────────────────────────── */
|
||
#body {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
#main-row {
|
||
flex: 1;
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ── FILE TREE ──────────────────────────────────────────────────────────────── */
|
||
#file-tree {
|
||
width: var(--file-w);
|
||
min-width: var(--file-w);
|
||
background: var(--bg2);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.panel-header {
|
||
height: 32px;
|
||
min-height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 12px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.08em;
|
||
color: var(--text3);
|
||
text-transform: uppercase;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#file-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 6px 0;
|
||
}
|
||
|
||
.file-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 12px;
|
||
cursor: pointer;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11.5px;
|
||
color: var(--text2);
|
||
transition: background 0.1s;
|
||
user-select: none;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.file-item:hover { background: var(--bg3); color: var(--text); }
|
||
.file-item.active { color: var(--accent); background: var(--accent-dim); }
|
||
|
||
.file-item .icon {
|
||
font-size: 10px;
|
||
flex-shrink: 0;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.file-item.dir { color: var(--text2); font-weight: 500; }
|
||
.file-item.dir .icon { color: var(--accent); opacity: 1; }
|
||
|
||
/* Git status badges in file tree */
|
||
.git-badge {
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
padding: 1px 4px;
|
||
border-radius: 3px;
|
||
margin-left: auto;
|
||
flex-shrink: 0;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.git-badge-M { background: rgba(251,191,36,0.15); color: var(--yellow); }
|
||
.git-badge-A { background: rgba(74,222,128,0.12); color: var(--green); }
|
||
.git-badge-D { background: rgba(248,113,113,0.12); color: var(--red); }
|
||
.git-badge-R { background: rgba(167,139,250,0.12); color: var(--purple); }
|
||
|
||
/* ── EDITOR ─────────────────────────────────────────────────────────────────── */
|
||
#editor-col {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: var(--bg);
|
||
position: relative;
|
||
}
|
||
|
||
#editor-tabs {
|
||
height: 32px;
|
||
min-height: 32px;
|
||
display: flex;
|
||
align-items: stretch;
|
||
background: var(--bg2);
|
||
border-bottom: 1px solid var(--border);
|
||
overflow-x: hidden;
|
||
flex-shrink: 0;
|
||
position: relative;
|
||
}
|
||
|
||
/* Tabs scroll container */
|
||
#editor-tabs-scroll {
|
||
display: flex;
|
||
align-items: stretch;
|
||
flex: 1;
|
||
overflow-x: auto;
|
||
scrollbar-width: none;
|
||
}
|
||
#editor-tabs-scroll::-webkit-scrollbar { display: none; }
|
||
|
||
/* Overflow button (▾) */
|
||
#tab-overflow-btn {
|
||
flex-shrink: 0;
|
||
width: 28px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
color: var(--text3);
|
||
border-left: 1px solid var(--border);
|
||
font-size: 10px;
|
||
transition: color 0.1s;
|
||
}
|
||
#tab-overflow-btn:hover { color: var(--text); background: var(--bg3); }
|
||
|
||
#tab-overflow-menu {
|
||
position: absolute;
|
||
top: 32px;
|
||
right: 0;
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||
z-index: 500;
|
||
min-width: 180px;
|
||
max-height: 240px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.tab-overflow-item {
|
||
padding: 7px 14px;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11.5px;
|
||
color: var(--text2);
|
||
cursor: pointer;
|
||
transition: background 0.1s;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.tab-overflow-item:hover { background: var(--bg3); color: var(--text); }
|
||
.tab-overflow-item.active-tab { color: var(--accent); }
|
||
|
||
.editor-tab {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 0 12px;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11.5px;
|
||
color: var(--text2);
|
||
cursor: pointer;
|
||
border-right: 1px solid var(--border);
|
||
white-space: nowrap;
|
||
transition: background 0.1s;
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.editor-tab.active {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
border-bottom: 2px solid var(--accent);
|
||
}
|
||
|
||
.editor-tab .dot {
|
||
width: 5px; height: 5px;
|
||
border-radius: 50%;
|
||
background: var(--orange);
|
||
display: none;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.editor-tab.unsaved .dot { display: block; }
|
||
|
||
.tab-close {
|
||
width: 14px; height: 14px;
|
||
border-radius: 2px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 10px;
|
||
color: var(--text3);
|
||
flex-shrink: 0;
|
||
transition: all 0.1s;
|
||
line-height: 1;
|
||
}
|
||
|
||
.tab-close:hover { background: var(--bg3); color: var(--red); }
|
||
|
||
#editor-wrapper {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
#cm-editor {
|
||
height: 100%;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.cm-editor { height: 100%; background: var(--bg) !important; }
|
||
.cm-editor.cm-focused { outline: none !important; }
|
||
.cm-content { color: var(--text); caret-color: var(--accent); }
|
||
.cm-gutters {
|
||
background: var(--bg2) !important;
|
||
border-right: 1px solid var(--border) !important;
|
||
color: var(--text3) !important;
|
||
}
|
||
.cm-activeLineGutter { background: var(--bg3) !important; }
|
||
.cm-activeLine { background: rgba(56,189,248,0.04) !important; }
|
||
.cm-selectionBackground { background: rgba(56,189,248,0.15) !important; }
|
||
.cm-matchingBracket { background: rgba(56,189,248,0.12) !important; border-radius: 2px; }
|
||
|
||
/* Autocompletion */
|
||
.cm-tooltip { background: var(--bg2) !important; border: 1px solid var(--border2) !important; }
|
||
.cm-tooltip-autocomplete ul { background: var(--bg2) !important; }
|
||
.cm-tooltip-autocomplete li { color: var(--text2) !important; font-family: 'DM Mono', monospace !important; font-size: 12px !important; }
|
||
.cm-tooltip-autocomplete li[aria-selected] { background: var(--accent-dim) !important; color: var(--accent) !important; }
|
||
.cm-completionIcon { opacity: 0.6; }
|
||
|
||
/* Syntax tokens */
|
||
.tok-keyword { color: var(--accent); }
|
||
.tok-keyword2 { color: var(--accent); font-style: italic; }
|
||
.tok-type { color: var(--purple); }
|
||
.tok-string { color: var(--green); }
|
||
.tok-number { color: var(--orange); }
|
||
.tok-comment { color: var(--text3); font-style: italic; }
|
||
.tok-operator { color: var(--text2); }
|
||
.tok-punctuation { color: var(--text3); }
|
||
.tok-builtin { color: var(--purple); opacity: 0.8; }
|
||
|
||
/* LSP squiggles */
|
||
.cm-lsp-error {
|
||
text-decoration: underline wavy var(--red);
|
||
text-decoration-skip-ink: none;
|
||
}
|
||
|
||
/* Code folding gutter */
|
||
.cm-foldGutter span { color: var(--text3); cursor: pointer; font-size: 12px; line-height: 1; }
|
||
.cm-foldGutter span:hover { color: var(--accent); }
|
||
|
||
/* Word highlight */
|
||
.cm-wordHighlight { background: rgba(56,189,248,0.12); border-radius: 2px; }
|
||
|
||
/* Find/Replace panel */
|
||
.cm-search { background: var(--bg2) !important; border-top: 1px solid var(--border2) !important; padding: 6px 10px !important; }
|
||
.cm-search input { background: var(--bg3) !important; border: 1px solid var(--border2) !important; border-radius: var(--radius) !important; color: var(--text) !important; font-family: 'DM Mono', monospace !important; font-size: 12px !important; padding: 3px 8px !important; margin: 2px 4px 2px 0 !important; }
|
||
.cm-search input:focus { border-color: var(--accent) !important; outline: none !important; }
|
||
.cm-search button { background: var(--bg3) !important; border: 1px solid var(--border2) !important; border-radius: var(--radius) !important; color: var(--text2) !important; font-family: 'Syne', sans-serif !important; font-size: 11px !important; padding: 3px 8px !important; cursor: pointer !important; margin: 0 2px !important; }
|
||
.cm-search button:hover { border-color: var(--accent) !important; color: var(--accent) !important; }
|
||
.cm-search label { color: var(--text3) !important; font-size: 11px !important; }
|
||
.cm-searchMatch { background: rgba(251,191,36,0.25) !important; border-radius: 2px; }
|
||
.cm-searchMatch.cm-searchMatch-selected { background: rgba(251,191,36,0.5) !important; }
|
||
|
||
/* ── STATUS BAR ─────────────────────────────────────────────────────────────── */
|
||
#status-bar {
|
||
height: 22px;
|
||
min-height: 22px;
|
||
background: var(--bg2);
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 12px;
|
||
gap: 0;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
color: var(--text3);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.status-sep { margin: 0 8px; opacity: 0.4; }
|
||
.status-spacer { flex: 1; }
|
||
#status-server { color: var(--green); }
|
||
|
||
/* ── NEURON PAIR PANEL ──────────────────────────────────────────────────────── */
|
||
#neuron-panel {
|
||
width: 300px;
|
||
min-width: 300px;
|
||
background: var(--bg2);
|
||
border-left: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.neuron-panel-header {
|
||
height: 40px;
|
||
min-height: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 12px;
|
||
gap: 8px;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
background: var(--bg2);
|
||
}
|
||
|
||
.neuron-panel-title {
|
||
font-family: 'Syne', sans-serif;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: var(--purple);
|
||
letter-spacing: 0.04em;
|
||
flex: 1;
|
||
}
|
||
|
||
.neuron-panel-status {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 9px;
|
||
color: var(--text3);
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.neuron-status-dot {
|
||
width: 6px; height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--text3);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.neuron-status-dot.connected {
|
||
background: var(--green);
|
||
box-shadow: 0 0 6px rgba(74,222,128,0.5);
|
||
animation: pulse-dot 2.5s ease-in-out infinite;
|
||
}
|
||
|
||
.neuron-status-dot.active {
|
||
background: var(--purple);
|
||
box-shadow: 0 0 8px rgba(167,139,250,0.6);
|
||
animation: pulse-dot 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
#neuron-messages {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.neuron-msg {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.neuron-msg-user {
|
||
align-self: flex-end;
|
||
background: var(--accent-dim);
|
||
border: 1px solid rgba(56,189,248,0.2);
|
||
border-radius: 8px 8px 2px 8px;
|
||
padding: 7px 10px;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11.5px;
|
||
color: var(--text);
|
||
max-width: 90%;
|
||
}
|
||
|
||
.neuron-msg-neuron {
|
||
align-self: flex-start;
|
||
background: rgba(139,92,246,0.08);
|
||
border: 1px solid rgba(139,92,246,0.15);
|
||
border-radius: 2px 8px 8px 8px;
|
||
padding: 8px 10px;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11.5px;
|
||
color: var(--text);
|
||
max-width: 100%;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.neuron-msg-label {
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
padding: 0 2px;
|
||
}
|
||
|
||
.neuron-msg-label.user-label { color: var(--accent); }
|
||
.neuron-msg-label.neuron-label { color: var(--purple); }
|
||
|
||
.neuron-code-block {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 4px;
|
||
margin: 6px 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.neuron-code-header {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 4px 10px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--bg4);
|
||
gap: 6px;
|
||
}
|
||
|
||
.neuron-code-lang {
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
color: var(--purple);
|
||
text-transform: uppercase;
|
||
flex: 1;
|
||
}
|
||
|
||
.neuron-code-insert {
|
||
font-family: 'Syne', sans-serif;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
color: var(--accent);
|
||
cursor: pointer;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
border: 1px solid rgba(56,189,248,0.3);
|
||
background: var(--accent-dim);
|
||
transition: all 0.1s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.neuron-code-insert:hover {
|
||
background: rgba(56,189,248,0.2);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.neuron-code-body {
|
||
padding: 8px 10px;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
color: var(--text);
|
||
white-space: pre;
|
||
overflow-x: auto;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.neuron-thinking {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 10px;
|
||
color: var(--text3);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.thinking-dots span {
|
||
display: inline-block;
|
||
animation: thinking-bounce 1.2s ease-in-out infinite;
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
}
|
||
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||
|
||
@keyframes thinking-bounce {
|
||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||
30% { transform: translateY(-4px); opacity: 1; }
|
||
}
|
||
|
||
#neuron-input-row {
|
||
border-top: 1px solid var(--border);
|
||
padding: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
background: var(--bg2);
|
||
}
|
||
|
||
#neuron-input {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11.5px;
|
||
padding: 7px 10px;
|
||
outline: none;
|
||
resize: none;
|
||
min-height: 36px;
|
||
max-height: 80px;
|
||
line-height: 1.4;
|
||
width: 100%;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
#neuron-input:focus { border-color: var(--purple); }
|
||
|
||
.neuron-quick-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.neuron-quick-btn {
|
||
font-family: 'Syne', sans-serif;
|
||
font-size: 9.5px;
|
||
font-weight: 600;
|
||
padding: 3px 7px;
|
||
border-radius: 3px;
|
||
border: 1px solid var(--border2);
|
||
background: var(--bg3);
|
||
color: var(--text3);
|
||
cursor: pointer;
|
||
transition: all 0.12s;
|
||
white-space: nowrap;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.neuron-quick-btn:hover {
|
||
border-color: var(--purple);
|
||
color: var(--purple);
|
||
background: rgba(139,92,246,0.08);
|
||
}
|
||
|
||
#neuron-send {
|
||
font-family: 'Syne', sans-serif;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
padding: 6px 14px;
|
||
border-radius: var(--radius);
|
||
border: 1px solid rgba(139,92,246,0.4);
|
||
background: rgba(139,92,246,0.12);
|
||
color: var(--purple);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
align-self: flex-end;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
#neuron-send:hover {
|
||
background: rgba(139,92,246,0.22);
|
||
box-shadow: 0 0 12px rgba(139,92,246,0.3);
|
||
}
|
||
|
||
/* Neuron panel toggle button (in header) */
|
||
#btn-neuron-toggle {
|
||
position: relative;
|
||
}
|
||
|
||
#btn-neuron-toggle.neuron-active {
|
||
background: rgba(139,92,246,0.12);
|
||
border-color: rgba(139,92,246,0.4);
|
||
color: var(--purple);
|
||
box-shadow: 0 0 8px rgba(139,92,246,0.2);
|
||
}
|
||
|
||
/* ── ACTIVATION PREVIEW ─────────────────────────────────────────────────────── */
|
||
#activation-preview {
|
||
position: absolute;
|
||
z-index: 200;
|
||
background: var(--bg2);
|
||
border: 1px solid rgba(139,92,246,0.4);
|
||
border-radius: 6px;
|
||
padding: 8px 12px;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.5), 0 0 24px rgba(139,92,246,0.1);
|
||
display: none;
|
||
max-width: 280px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
#activation-preview.visible { display: block; }
|
||
|
||
.ap-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.ap-icon { color: var(--purple); font-size: 12px; }
|
||
.ap-count { color: var(--text); font-weight: 600; }
|
||
.ap-type { color: var(--purple); }
|
||
.ap-query { color: var(--text3); font-size: 10px; }
|
||
|
||
.ap-nodes {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
|
||
.ap-node {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 2px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.ap-node:last-child { border-bottom: none; }
|
||
|
||
.ap-node-label { color: var(--text2); flex: 1; }
|
||
.ap-node-score {
|
||
font-size: 9.5px;
|
||
color: var(--purple);
|
||
padding: 1px 4px;
|
||
border-radius: 2px;
|
||
background: rgba(139,92,246,0.12);
|
||
}
|
||
|
||
.ap-disconnected { color: var(--text3); font-size: 10px; font-style: italic; }
|
||
|
||
/* ── SEMANTIC COMPLETION BADGE ──────────────────────────────────────────────── */
|
||
.cm-semantic-completion {
|
||
position: relative;
|
||
}
|
||
|
||
/* ── TYPE GRAPH ─────────────────────────────────────────────────────────────── */
|
||
#type-graph {
|
||
width: var(--graph-w);
|
||
min-width: var(--graph-w);
|
||
background: var(--bg2);
|
||
border-left: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
#graph-canvas { flex: 1; width: 100%; cursor: grab; }
|
||
#graph-canvas:active { cursor: grabbing; }
|
||
|
||
/* Type graph node details */
|
||
#graph-node-detail {
|
||
padding: 6px 12px;
|
||
border-top: 1px solid var(--border);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
color: var(--text2);
|
||
flex-shrink: 0;
|
||
min-height: 28px;
|
||
display: none;
|
||
}
|
||
#graph-node-detail.visible { display: block; }
|
||
.gnd-name { color: var(--accent); font-weight: 600; }
|
||
.gnd-kind { color: var(--text3); }
|
||
|
||
/* Graph zoom controls */
|
||
#graph-controls {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 4px 8px;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
align-items: center;
|
||
}
|
||
.graph-btn {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 3px;
|
||
color: var(--text2);
|
||
font-size: 13px;
|
||
width: 22px;
|
||
height: 22px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.1s;
|
||
line-height: 1;
|
||
}
|
||
.graph-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||
.graph-zoom-label { font-family: 'DM Mono', monospace; font-size: 10px; color: var(--text3); margin-left: 2px; }
|
||
|
||
/* ── MINIMAP ──────────────────────────────────────────────────────────────── */
|
||
#minimap-container {
|
||
position: absolute;
|
||
top: 0; right: 0;
|
||
width: 60px;
|
||
height: 100%;
|
||
pointer-events: all;
|
||
overflow: hidden;
|
||
z-index: 10;
|
||
opacity: 0.6;
|
||
cursor: pointer;
|
||
transition: opacity 0.15s;
|
||
}
|
||
#minimap-container:hover { opacity: 0.9; }
|
||
#minimap-canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
}
|
||
#minimap-viewport {
|
||
position: absolute;
|
||
right: 0;
|
||
width: 100%;
|
||
background: rgba(56,189,248,0.1);
|
||
border: 1px solid rgba(56,189,248,0.3);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ── RESIZABLE PANELS ─────────────────────────────────────────────────────── */
|
||
.panel-resize-handle {
|
||
width: 4px;
|
||
background: transparent;
|
||
cursor: ew-resize;
|
||
flex-shrink: 0;
|
||
transition: background 0.15s;
|
||
z-index: 20;
|
||
}
|
||
.panel-resize-handle:hover { background: var(--accent-dim); }
|
||
|
||
#graph-legend {
|
||
padding: 8px 12px;
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 10px;
|
||
color: var(--text3);
|
||
}
|
||
|
||
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||
|
||
/* ── BOTTOM PANEL ───────────────────────────────────────────────────────────── */
|
||
#bottom-panel {
|
||
height: var(--bottom-h);
|
||
min-height: var(--bottom-h);
|
||
background: var(--bg2);
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#bottom-tabs {
|
||
display: flex;
|
||
align-items: stretch;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.bottom-tab {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 0 14px;
|
||
height: 32px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
color: var(--text3);
|
||
cursor: pointer;
|
||
border-right: 1px solid var(--border);
|
||
transition: color 0.1s;
|
||
user-select: none;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.bottom-tab:hover { color: var(--text2); }
|
||
.bottom-tab.active { color: var(--accent); border-bottom: 2px solid var(--accent); }
|
||
|
||
.bottom-tab .badge {
|
||
background: rgba(248,113,113,0.15);
|
||
color: var(--red);
|
||
border-radius: 10px;
|
||
padding: 1px 5px;
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
#bottom-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px 12px;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11.5px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.bottom-panel-content { display: none; }
|
||
.bottom-panel-content.active { display: block; }
|
||
|
||
/* Build output */
|
||
.out-line { display: block; margin: 1px 0; }
|
||
.out-line.error { color: var(--red); }
|
||
.out-line.warning { color: var(--yellow); }
|
||
.out-line.success { color: var(--green); }
|
||
.out-line.info { color: var(--text2); }
|
||
.out-line.output { color: var(--text); }
|
||
|
||
/* Problems */
|
||
.problem-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
padding: 5px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
cursor: pointer;
|
||
transition: background 0.1s;
|
||
border-radius: 2px;
|
||
}
|
||
.problem-item:hover { background: var(--bg3); padding-left: 4px; }
|
||
.problem-item:last-child { border-bottom: none; }
|
||
.problem-icon { flex-shrink: 0; font-size: 11px; }
|
||
.problem-icon.error { color: var(--red); }
|
||
.problem-icon.warning { color: var(--yellow); }
|
||
.problem-msg { color: var(--text2); flex: 1; }
|
||
.problem-loc { color: var(--text3); font-size: 10px; white-space: nowrap; }
|
||
.no-problems { color: var(--text3); padding: 8px 0; }
|
||
|
||
/* Search panel */
|
||
.search-input-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
||
#search-input {
|
||
flex: 1;
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 12px;
|
||
padding: 5px 10px;
|
||
outline: none;
|
||
}
|
||
#search-input:focus { border-color: var(--accent); }
|
||
#search-replace-input {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 12px;
|
||
padding: 5px 10px;
|
||
outline: none;
|
||
}
|
||
#search-replace-input:focus { border-color: var(--accent); }
|
||
.search-toggle-btn {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text2);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
padding: 0 7px;
|
||
height: 28px;
|
||
cursor: pointer;
|
||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
.search-toggle-btn:hover { border-color: var(--accent); color: var(--text); }
|
||
.search-toggle-btn.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
|
||
.search-result {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
padding: 4px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
cursor: pointer;
|
||
transition: background 0.1s;
|
||
border-radius: 2px;
|
||
}
|
||
.search-result:hover { background: var(--bg3); }
|
||
.search-result:last-child { border-bottom: none; }
|
||
.search-path { color: var(--accent); font-size: 10px; flex-shrink: 0; }
|
||
.search-loc { color: var(--text3); font-size: 10px; flex-shrink: 0; }
|
||
.search-snippet { color: var(--text2); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||
|
||
/* Reasoning */
|
||
.reason-input-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
||
#reason-input {
|
||
flex: 1;
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 12px;
|
||
padding: 5px 10px;
|
||
outline: none;
|
||
}
|
||
#reason-input:focus { border-color: var(--accent); }
|
||
#reason-output { font-size: 12px; }
|
||
.verdict { font-weight: 700; margin-bottom: 4px; }
|
||
.verdict.supported { color: var(--green); }
|
||
.verdict.refuted { color: var(--red); }
|
||
.verdict.unresolved{ color: var(--text2); }
|
||
.confidence { color: var(--text3); font-size: 11px; margin-bottom: 8px; }
|
||
.evidence-list { list-style: none; }
|
||
.evidence-item {
|
||
padding: 3px 0 3px 10px;
|
||
border-left: 2px solid var(--border2);
|
||
color: var(--text2);
|
||
margin-bottom: 4px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
/* Knowledge Graph explorer panel */
|
||
.kg-search-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
||
#kg-search-input {
|
||
flex: 1;
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 12px;
|
||
padding: 5px 10px;
|
||
outline: none;
|
||
}
|
||
#kg-search-input:focus { border-color: var(--purple); box-shadow: 0 0 0 1px rgba(139,92,246,0.2); }
|
||
|
||
.kg-node-card {
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
padding: 8px 10px;
|
||
margin-bottom: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
background: var(--bg3);
|
||
}
|
||
.kg-node-card:hover { border-color: var(--purple); background: rgba(139,92,246,0.06); }
|
||
.kg-node-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||
.kg-node-label { font-weight: 600; color: var(--text); font-family: 'DM Mono', monospace; font-size: 12px; flex: 1; }
|
||
.kg-node-type {
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
padding: 1px 5px;
|
||
border-radius: 3px;
|
||
background: rgba(139,92,246,0.12);
|
||
color: var(--purple);
|
||
text-transform: uppercase;
|
||
flex-shrink: 0;
|
||
}
|
||
.kg-node-score {
|
||
font-size: 10px;
|
||
color: var(--text3);
|
||
font-family: 'DM Mono', monospace;
|
||
flex-shrink: 0;
|
||
}
|
||
.kg-node-id { font-size: 10px; color: var(--text3); font-family: 'DM Mono', monospace; }
|
||
.kg-node-actions { display: flex; gap: 6px; margin-top: 6px; }
|
||
.kg-use-btn {
|
||
font-family: 'Syne', sans-serif;
|
||
font-size: 9.5px;
|
||
font-weight: 600;
|
||
padding: 2px 8px;
|
||
border-radius: 3px;
|
||
border: 1px solid rgba(139,92,246,0.3);
|
||
background: rgba(139,92,246,0.08);
|
||
color: var(--purple);
|
||
cursor: pointer;
|
||
transition: all 0.1s;
|
||
}
|
||
.kg-use-btn:hover { background: rgba(139,92,246,0.18); }
|
||
.kg-status-msg { color: var(--text3); font-size: 11px; padding: 8px 0; font-style: italic; }
|
||
|
||
/* Plugins */
|
||
.plugin-section-title {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--text3);
|
||
padding: 8px 0 4px;
|
||
border-bottom: 1px solid var(--border);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.plugin-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 6px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.plugin-item:last-child { border-bottom: none; }
|
||
.plugin-name { font-weight: 600; color: var(--text); }
|
||
.plugin-desc { font-size: 11px; color: var(--text3); margin-top: 1px; }
|
||
.plugin-meta { display: flex; flex-direction: column; flex: 1; }
|
||
.plugin-badges { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 3px; }
|
||
.plugin-badge {
|
||
padding: 1px 6px;
|
||
border-radius: 10px;
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
flex-shrink: 0;
|
||
}
|
||
.plugin-badge.installed { background: rgba(74,222,128,0.12); color: var(--green); }
|
||
.plugin-badge.available { background: var(--bg3); color: var(--text3); }
|
||
.plugin-badge.first-party { background: rgba(56,189,248,0.10); color: var(--accent); }
|
||
.plugin-badge.hook { background: rgba(167,139,250,0.12); color: var(--purple); font-size: 8px; }
|
||
|
||
/* Settings */
|
||
.settings-section { margin-bottom: 16px; }
|
||
.settings-title {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--text3);
|
||
padding-bottom: 6px;
|
||
border-bottom: 1px solid var(--border);
|
||
margin-bottom: 8px;
|
||
}
|
||
.settings-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 4px 0;
|
||
font-size: 12px;
|
||
}
|
||
.settings-row label { color: var(--text2); flex: 1; }
|
||
.settings-row input[type="range"] { width: 100px; accent-color: var(--accent); cursor: pointer; }
|
||
.settings-row select {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
padding: 3px 8px;
|
||
cursor: pointer;
|
||
outline: none;
|
||
}
|
||
.settings-row input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; width: 14px; height: 14px; }
|
||
.settings-val { color: var(--text3); font-size: 11px; width: 32px; }
|
||
|
||
.settings-theme-grid { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
|
||
.settings-theme-btn {
|
||
padding: 5px 14px;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border2);
|
||
background: var(--bg3);
|
||
color: var(--text2);
|
||
font-family: 'Syne', sans-serif;
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
.settings-theme-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||
.settings-theme-btn.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
|
||
|
||
/* Shortcuts */
|
||
.shortcuts-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||
.shortcuts-table tr { border-bottom: 1px solid var(--border); }
|
||
.shortcuts-table tr:last-child { border-bottom: none; }
|
||
.shortcuts-table td { padding: 5px 0; }
|
||
.shortcuts-table td:first-child { color: var(--text2); width: 50%; }
|
||
.shortcuts-table kbd {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 3px;
|
||
padding: 1px 5px;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 10px;
|
||
color: var(--text);
|
||
}
|
||
|
||
/* Terminal panel */
|
||
#panel-terminal {
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background: #0a0a0a;
|
||
padding: 0;
|
||
}
|
||
#xterm-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
/* Hide the default terminal scrollbar (xterm handles it) */
|
||
#panel-terminal::-webkit-scrollbar { display: none; }
|
||
|
||
/* Sticky scroll header */
|
||
#sticky-scroll {
|
||
background: var(--bg3);
|
||
border-bottom: 1px solid var(--border);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11.5px;
|
||
color: var(--text2);
|
||
padding: 3px 12px;
|
||
flex-shrink: 0;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
display: none;
|
||
opacity: 0.85;
|
||
}
|
||
#sticky-scroll { cursor: pointer; }
|
||
#sticky-scroll:hover { background: var(--bg2); }
|
||
#sticky-scroll .sticky-kind { color: var(--accent); margin-right: 4px; }
|
||
#sticky-scroll .sticky-name { color: var(--text); }
|
||
#sticky-scroll .sticky-goto { color: var(--text3); margin-left: 6px; font-size: 10px; }
|
||
#sticky-scroll.visible { display: block; }
|
||
|
||
/* Inline diff panel */
|
||
#panel-diff { padding: 0; }
|
||
.diff-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
#diff-file-label { color: var(--text2); font-family: 'DM Mono', monospace; font-size: 11px; flex: 1; }
|
||
#diff-output {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
padding: 8px 12px;
|
||
}
|
||
.diff-line { display: block; white-space: pre; }
|
||
.diff-add { color: var(--green); background: rgba(74,222,128,0.05); }
|
||
.diff-del { color: var(--red); background: rgba(248,113,113,0.05); }
|
||
.diff-hunk { color: var(--accent); opacity: 0.7; }
|
||
.diff-meta { color: var(--text3); }
|
||
.diff-ctx { color: var(--text3); }
|
||
|
||
/* Tab keyboard shortcut hint */
|
||
.editor-tab[data-hotkey]::after {
|
||
content: attr(data-hotkey);
|
||
position: absolute;
|
||
top: 2px;
|
||
right: 14px;
|
||
font-size: 8px;
|
||
color: var(--text3);
|
||
display: none;
|
||
}
|
||
|
||
/* Outline panel */
|
||
.outline-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 3px 8px 3px 12px;
|
||
cursor: pointer;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11.5px;
|
||
color: var(--text2);
|
||
transition: background 0.1s;
|
||
border-radius: 2px;
|
||
}
|
||
.outline-item:hover { background: var(--bg3); color: var(--text); }
|
||
.outline-kind {
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.05em;
|
||
padding: 1px 4px;
|
||
border-radius: 3px;
|
||
flex-shrink: 0;
|
||
width: 36px;
|
||
text-align: center;
|
||
}
|
||
.outline-kind-fn { background: rgba(56,189,248,0.12); color: var(--accent); }
|
||
.outline-kind-type { background: rgba(167,139,250,0.12); color: var(--purple); }
|
||
.outline-kind-enum { background: rgba(167,139,250,0.12); color: var(--purple); }
|
||
.outline-kind-protocol { background: rgba(74,222,128,0.12); color: var(--green); }
|
||
.outline-kind-impl { background: rgba(251,191,36,0.10); color: var(--yellow); }
|
||
.outline-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.outline-line { color: var(--text3); font-size: 10px; flex-shrink: 0; }
|
||
|
||
/* Resizable bottom panel */
|
||
#bottom-resize-handle {
|
||
height: 4px;
|
||
background: transparent;
|
||
cursor: ns-resize;
|
||
flex-shrink: 0;
|
||
transition: background 0.15s;
|
||
}
|
||
#bottom-resize-handle:hover { background: var(--accent-dim); }
|
||
|
||
/* Vim mode indicator in status bar */
|
||
#status-vim-mode {
|
||
color: var(--orange);
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
/* Clickable build error lines */
|
||
.out-line.error.clickable, .out-line.warning.clickable {
|
||
cursor: pointer;
|
||
text-decoration: underline dotted;
|
||
}
|
||
.out-line.error.clickable:hover { opacity: 0.85; }
|
||
|
||
/* Scrollbars */
|
||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; transition: background 0.15s; }
|
||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
|
||
::-webkit-scrollbar-corner { background: transparent; }
|
||
|
||
/* File tree file extension icons (via pseudo-element colors) */
|
||
.file-item[data-path$=".el"] .icon { color: var(--accent) !important; opacity: 1; }
|
||
.file-item[data-path$=".rs"] .icon { color: var(--orange) !important; opacity: 1; }
|
||
.file-item[data-path$=".toml"] .icon { color: var(--yellow) !important; opacity: 0.9; }
|
||
.file-item[data-path$=".json"] .icon { color: var(--green) !important; opacity: 0.9; }
|
||
.file-item[data-path$=".md"] .icon { color: var(--text2) !important; opacity: 0.9; }
|
||
.file-item[data-path$=".lock"] .icon { color: var(--text3) !important; opacity: 0.7; }
|
||
|
||
/* Notification toast */
|
||
#toast {
|
||
position: fixed;
|
||
bottom: 40px;
|
||
left: 50%;
|
||
transform: translateX(-50%) translateY(20px);
|
||
background: var(--bg4);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 6px;
|
||
padding: 8px 18px;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
z-index: 900;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 0.2s, transform 0.2s;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||
white-space: nowrap;
|
||
}
|
||
#toast.show {
|
||
opacity: 1;
|
||
transform: translateX(-50%) translateY(0);
|
||
}
|
||
|
||
/* Tooltip */
|
||
.tooltip {
|
||
position: fixed;
|
||
background: var(--bg4);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
padding: 8px 12px;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
max-width: 320px;
|
||
z-index: 1000;
|
||
pointer-events: none;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.tooltip .type-label { color: var(--purple); font-weight: 600; }
|
||
|
||
/* ── CONTEXT MENU ───────────────────────────────────────────────────────────── */
|
||
#ctx-menu {
|
||
position: fixed;
|
||
z-index: 600;
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||
min-width: 140px;
|
||
overflow: hidden;
|
||
}
|
||
.ctx-item {
|
||
padding: 7px 14px;
|
||
font-size: 12px;
|
||
color: var(--text2);
|
||
cursor: pointer;
|
||
transition: background 0.1s;
|
||
}
|
||
.ctx-item:hover { background: var(--bg3); color: var(--text); }
|
||
.ctx-item.ctx-danger { color: var(--red); }
|
||
.ctx-item.ctx-danger:hover { background: rgba(248,113,113,0.08); }
|
||
.ctx-sep { height: 1px; background: var(--border); margin: 4px 0; }
|
||
|
||
/* ── QUICK OPEN MODAL ───────────────────────────────────────────────────────── */
|
||
#quick-open-modal {
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0,0,0,0.5);
|
||
z-index: 700;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
padding-top: 80px;
|
||
}
|
||
|
||
#quick-open-box {
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 8px;
|
||
box-shadow: 0 16px 48px rgba(0,0,0,0.6);
|
||
width: 500px;
|
||
max-width: 90vw;
|
||
overflow: hidden;
|
||
}
|
||
|
||
#quick-open-input {
|
||
width: 100%;
|
||
background: transparent;
|
||
border: none;
|
||
border-bottom: 1px solid var(--border);
|
||
color: var(--text);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 14px;
|
||
padding: 14px 16px;
|
||
outline: none;
|
||
}
|
||
|
||
.qo-hint {
|
||
padding: 6px 16px;
|
||
font-size: 10px;
|
||
color: var(--text3);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.qo-result .qo-icon {
|
||
font-size: 11px;
|
||
color: var(--text3);
|
||
flex-shrink: 0;
|
||
width: 16px;
|
||
}
|
||
|
||
#quick-open-results {
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.qo-result {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 16px;
|
||
cursor: pointer;
|
||
transition: background 0.1s;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 12px;
|
||
}
|
||
.qo-result:hover, .qo-result.selected { background: var(--accent-dim); }
|
||
.qo-name { color: var(--text); flex: 1; }
|
||
.qo-path { color: var(--text3); font-size: 10px; }
|
||
.qo-mark { color: var(--accent); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ═══ HEADER ═══════════════════════════════════════════════════════════════ -->
|
||
<div id="header">
|
||
<div class="logo">
|
||
<div class="logo-dot"></div>
|
||
<div class="logo-mark">el<span>-ide</span></div>
|
||
</div>
|
||
<div class="header-sep"></div>
|
||
<div class="project-name" id="project-name-label">
|
||
project / <span id="project-name-span">…</span>
|
||
</div>
|
||
<div class="header-spacer"></div>
|
||
<select class="target-select" id="target-select">
|
||
<option value="debug">debug</option>
|
||
<option value="release">release</option>
|
||
<option value="prod">prod</option>
|
||
</select>
|
||
<button class="btn btn-primary" id="btn-build">Build</button>
|
||
<button class="btn btn-run" id="btn-run">Run</button>
|
||
<button class="btn" id="btn-check">Check</button>
|
||
<div class="header-sep"></div>
|
||
<button class="btn" id="btn-neuron-toggle" title="Toggle Neuron pair programming panel">Neuron</button>
|
||
<div class="header-sep"></div>
|
||
<div class="theme-dropdown">
|
||
<button class="btn" id="btn-theme">Theme</button>
|
||
<div class="theme-menu" id="theme-menu" style="display:none">
|
||
<div class="theme-option" data-theme="dark">Dark</div>
|
||
<div class="theme-option" data-theme="light">Light</div>
|
||
<div class="theme-option" data-theme="neuron">Neuron</div>
|
||
<div class="theme-option" data-theme="high-contrast">High Contrast</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ BREADCRUMB ═══════════════════════════════════════════════════════════ -->
|
||
<div id="breadcrumb"><span style="color:var(--text3)">no file open</span></div>
|
||
|
||
<!-- ═══ BODY ═════════════════════════════════════════════════════════════════ -->
|
||
<div id="body">
|
||
<div id="main-row">
|
||
|
||
<!-- ── FILE TREE ──────────────────────────────────────────────────────── -->
|
||
<div id="file-tree">
|
||
<div class="panel-header" style="display:flex;align-items:center;gap:4px;">
|
||
<span style="flex:1">Files</span>
|
||
<button class="graph-btn" id="btn-new-file" title="New file" style="width:18px;height:18px;font-size:13px;line-height:1;">+</button>
|
||
<button class="graph-btn" id="btn-refresh-tree" title="Refresh file tree" style="width:18px;height:18px;font-size:11px;">↺</button>
|
||
<button class="graph-btn" id="btn-collapse-tree" title="Collapse tree (Ctrl+B)" style="width:18px;height:18px;font-size:10px;">‹</button>
|
||
</div>
|
||
<div id="file-list">
|
||
<div style="color:var(--text3);padding:12px;font-size:11px;">Loading...</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel-resize-handle" id="file-tree-resize"></div>
|
||
|
||
<!-- ── EDITOR ─────────────────────────────────────────────────────────── -->
|
||
<div id="editor-col">
|
||
<div id="editor-tabs">
|
||
<div id="editor-tabs-scroll"></div>
|
||
<div id="tab-overflow-btn" title="All tabs">▾</div>
|
||
<div id="tab-overflow-menu" style="display:none"></div>
|
||
</div>
|
||
<div id="sticky-scroll"></div>
|
||
<div id="editor-wrapper">
|
||
<div id="cm-editor"></div>
|
||
<div id="minimap-container">
|
||
<canvas id="minimap-canvas"></canvas>
|
||
<div id="minimap-viewport"></div>
|
||
</div>
|
||
</div>
|
||
<!-- Status bar sits between editor and bottom panel -->
|
||
<div id="status-bar">
|
||
<span id="status-file">no file</span>
|
||
<span class="status-sep">|</span>
|
||
<span id="status-pos">Ln 1, Col 1</span>
|
||
<span class="status-sep">|</span>
|
||
<span id="status-sel" style="display:none"></span>
|
||
<span id="status-sel-sep" class="status-sep" style="display:none">|</span>
|
||
<span id="status-lang">engram</span>
|
||
<div class="status-spacer"></div>
|
||
<span id="status-lines" style="color:var(--text3)">0 lines</span>
|
||
<span class="status-sep">|</span>
|
||
<span id="status-encoding">UTF-8</span>
|
||
<span class="status-sep">|</span>
|
||
<span id="status-vim-mode" style="display:none"></span>
|
||
<span id="status-el-ver" style="color:var(--text3);cursor:default" title="El compiler version"></span>
|
||
<span id="status-el-ver-sep" class="status-sep" style="display:none">|</span>
|
||
<span id="status-errors" style="display:none;color:var(--red);cursor:pointer" title="Errors — click to open Problems" onclick="switchBottomTab('problems')"></span>
|
||
<span id="status-warnings" style="display:none;color:var(--yellow);cursor:pointer" title="Warnings — click to open Problems" onclick="switchBottomTab('problems')"></span>
|
||
<span id="status-server">● connected</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── TYPE GRAPH ─────────────────────────────────────────────────────── -->
|
||
<div id="type-graph">
|
||
<div class="panel-header">Type Graph</div>
|
||
<div id="graph-controls">
|
||
<button class="graph-btn" id="graph-zoom-in" title="Zoom in">+</button>
|
||
<button class="graph-btn" id="graph-zoom-out" title="Zoom out">−</button>
|
||
<button class="graph-btn" id="graph-reset" title="Reset view">⌂</button>
|
||
<span class="graph-zoom-label" id="graph-zoom-label">100%</span>
|
||
</div>
|
||
<canvas id="graph-canvas"></canvas>
|
||
<div id="graph-node-detail"></div>
|
||
<div id="graph-legend">
|
||
<div class="legend-item">
|
||
<div class="legend-dot" style="background:#4a5a6a"></div>
|
||
<span>builtin</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-dot" style="background:#38bdf8"></div>
|
||
<span>struct</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-dot" style="background:#a78bfa"></div>
|
||
<span>enum</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-dot" style="background:#3a4a5a"></div>
|
||
<span>primitive</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-dot" style="background:#4ade80"></div>
|
||
<span>protocol</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-dot" style="background:#c084fc;box-shadow:0 0 6px rgba(192,132,252,0.5)"></div>
|
||
<span>activated</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── NEURON PAIR PROGRAMMING PANEL ────────────────────────────────── -->
|
||
<div id="neuron-panel" style="display:none">
|
||
<div class="neuron-panel-header">
|
||
<div class="neuron-status-dot" id="neuron-status-dot"></div>
|
||
<div class="neuron-panel-title">Neuron</div>
|
||
<div class="neuron-panel-status" id="neuron-panel-status">disconnected</div>
|
||
</div>
|
||
<div id="neuron-messages">
|
||
<div class="neuron-msg">
|
||
<div class="neuron-msg-label neuron-label">Neuron</div>
|
||
<div class="neuron-msg-neuron">Hello. I'm Neuron — your pair programmer for Engram.
|
||
|
||
I can see your current file, understand the type graph, and help you write code that leverages the knowledge graph.
|
||
|
||
Try: "explain this selection", "suggest an activate query", or ask me anything about your code.</div>
|
||
</div>
|
||
</div>
|
||
<div id="neuron-input-row">
|
||
<div class="neuron-quick-actions">
|
||
<button class="neuron-quick-btn" id="nq-explain">Explain selection</button>
|
||
<button class="neuron-quick-btn" id="nq-activate">Find activations</button>
|
||
<button class="neuron-quick-btn" id="nq-types">Analyze types</button>
|
||
<button class="neuron-quick-btn" id="nq-clear">Clear</button>
|
||
</div>
|
||
<textarea id="neuron-input" placeholder="Ask Neuron anything about your code..." rows="2"></textarea>
|
||
<button id="neuron-send">Send</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /#main-row -->
|
||
|
||
<!-- Activation preview floating widget (positioned by JS) -->
|
||
<div id="activation-preview">
|
||
<div class="ap-header">
|
||
<span class="ap-icon">⟁</span>
|
||
<span class="ap-count" id="ap-count">0</span>
|
||
<span style="color:var(--text3);font-size:10px;">nodes for</span>
|
||
<span class="ap-type" id="ap-type-name"></span>
|
||
</div>
|
||
<div class="ap-query" id="ap-query-text"></div>
|
||
<div class="ap-nodes" id="ap-nodes-list"></div>
|
||
</div>
|
||
|
||
<!-- ═══ BOTTOM PANEL ═════════════════════════════════════════════════════ -->
|
||
<div id="bottom-resize-handle"></div>
|
||
<div id="bottom-panel">
|
||
<div id="bottom-tabs">
|
||
<div class="bottom-tab active" data-panel="build">Build Output</div>
|
||
<div class="bottom-tab" data-panel="problems">
|
||
Problems <span class="badge" id="problems-badge" style="display:none">0</span>
|
||
</div>
|
||
<div class="bottom-tab" data-panel="search">Search</div>
|
||
<div class="bottom-tab" data-panel="terminal">Terminal</div>
|
||
<div class="bottom-tab" data-panel="outline">Outline</div>
|
||
<div class="bottom-tab" data-panel="diff">Git Diff</div>
|
||
<div class="bottom-tab" data-panel="reasoning">Reasoning</div>
|
||
<div class="bottom-tab" data-panel="knowledge">Knowledge Graph</div>
|
||
<div class="bottom-tab" data-panel="plugins">Plugins</div>
|
||
<div class="bottom-tab" data-panel="settings">Settings</div>
|
||
</div>
|
||
<div id="bottom-content">
|
||
|
||
<!-- Build output -->
|
||
<div id="panel-build" class="bottom-panel-content active">
|
||
<span class="out-line info">el-ide ready. Press Build to compile.</span>
|
||
</div>
|
||
|
||
<!-- Problems -->
|
||
<div id="panel-problems" class="bottom-panel-content">
|
||
<div class="no-problems">No problems detected.</div>
|
||
</div>
|
||
|
||
<!-- Search -->
|
||
<div id="panel-search" class="bottom-panel-content">
|
||
<div class="search-input-row">
|
||
<input id="search-input" type="text" placeholder="Search in project...">
|
||
<button class="search-toggle-btn" id="search-toggle-case" title="Match case (Alt+C)" style="font-size:11px;padding:0 6px;">Aa</button>
|
||
<button class="search-toggle-btn" id="search-toggle-word" title="Match whole word (Alt+W)" style="font-size:11px;padding:0 6px;">ab</button>
|
||
<button class="search-toggle-btn" id="search-toggle-regex" title="Use regex (Alt+R)" style="font-size:11px;padding:0 6px;">.*</button>
|
||
<input id="search-replace-input" type="text" placeholder="Replace..." style="flex:0.7">
|
||
<button class="btn btn-primary" id="btn-search">Search</button>
|
||
<button class="btn" id="btn-replace-all" title="Replace all">Replace all</button>
|
||
</div>
|
||
<div id="search-results"></div>
|
||
</div>
|
||
|
||
<!-- Terminal -->
|
||
<div id="panel-terminal" class="bottom-panel-content">
|
||
<div id="xterm-container"></div>
|
||
</div>
|
||
|
||
<!-- Git Diff -->
|
||
<div id="panel-diff" class="bottom-panel-content">
|
||
<div class="diff-toolbar">
|
||
<span id="diff-file-label">No file selected</span>
|
||
<button class="btn btn-primary" id="btn-diff-refresh" style="font-size:10px;padding:3px 8px">Refresh</button>
|
||
</div>
|
||
<div id="diff-output"><span style="color:var(--text3);font-size:11px;">Select a file and click Refresh to see its diff.</span></div>
|
||
</div>
|
||
|
||
<!-- Outline -->
|
||
<div id="panel-outline" class="bottom-panel-content">
|
||
<div id="outline-list"><div style="color:var(--text3);padding:8px 0;font-size:11px;">Open a file to see its outline.</div></div>
|
||
</div>
|
||
|
||
<!-- Reasoning -->
|
||
<div id="panel-reasoning" class="bottom-panel-content">
|
||
<div class="reason-input-row">
|
||
<input id="reason-input" type="text" placeholder="Enter a hypothesis about your code...">
|
||
<button class="btn btn-primary" id="btn-reason">Reason</button>
|
||
</div>
|
||
<div id="reason-output">
|
||
<div style="color:var(--text3);font-size:11px;">Enter a hypothesis and press Reason to query the Engram knowledge graph.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Knowledge Graph Explorer -->
|
||
<div id="panel-knowledge" class="bottom-panel-content">
|
||
<div class="kg-search-row">
|
||
<input id="kg-search-input" type="text" placeholder="Search knowledge graph concepts...">
|
||
<button class="btn btn-primary" id="btn-kg-search" style="white-space:nowrap">Search</button>
|
||
</div>
|
||
<div id="kg-results"><div class="kg-status-msg">Search the Engram knowledge graph to discover available data concepts.</div></div>
|
||
</div>
|
||
|
||
<!-- Plugins -->
|
||
<div id="panel-plugins" class="bottom-panel-content">
|
||
<div id="plugin-list">Loading...</div>
|
||
</div>
|
||
|
||
<!-- Settings -->
|
||
<div id="panel-settings" class="bottom-panel-content">
|
||
<div class="settings-section">
|
||
<div class="settings-title">Editor</div>
|
||
<div class="settings-row">
|
||
<label>Font size</label>
|
||
<input type="range" min="10" max="22" value="13" id="setting-font-size">
|
||
<span class="settings-val" id="setting-font-size-val">13px</span>
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Tab size</label>
|
||
<select id="setting-tab-size">
|
||
<option>2</option><option selected>4</option><option>8</option>
|
||
</select>
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Word wrap</label>
|
||
<input type="checkbox" id="setting-word-wrap">
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Vim keybindings</label>
|
||
<input type="checkbox" id="setting-vim-mode">
|
||
</div>
|
||
<div class="settings-row">
|
||
<label>Format on save</label>
|
||
<input type="checkbox" id="setting-format-on-save">
|
||
</div>
|
||
</div>
|
||
<div class="settings-section">
|
||
<div class="settings-title">Theme</div>
|
||
<div class="settings-theme-grid" id="settings-theme-grid">
|
||
<button class="settings-theme-btn active" data-theme="dark">Dark</button>
|
||
<button class="settings-theme-btn" data-theme="light">Light</button>
|
||
<button class="settings-theme-btn" data-theme="neuron">Neuron</button>
|
||
<button class="settings-theme-btn" data-theme="high-contrast">High Contrast</button>
|
||
</div>
|
||
</div>
|
||
<div class="settings-section">
|
||
<div class="settings-title">Keyboard Shortcuts</div>
|
||
<table class="shortcuts-table">
|
||
<tr><td>Save file</td><td><kbd>Cmd</kbd> + <kbd>S</kbd></td></tr>
|
||
<tr><td>Close tab</td><td><kbd>Cmd</kbd> + <kbd>W</kbd> or middle-click</td></tr>
|
||
<tr><td>Cycle tabs</td><td><kbd>Cmd</kbd> + <kbd>Tab</kbd></td></tr>
|
||
<tr><td>Switch to tab 1-9</td><td><kbd>Cmd</kbd> + <kbd>1</kbd>…<kbd>9</kbd></td></tr>
|
||
<tr><td>Find / Replace</td><td><kbd>Cmd</kbd> + <kbd>F</kbd> / <kbd>H</kbd></td></tr>
|
||
<tr><td>Search project</td><td><kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>F</kbd></td></tr>
|
||
<tr><td>Quick open files</td><td><kbd>Cmd</kbd> + <kbd>P</kbd></td></tr>
|
||
<tr><td>Command palette</td><td><kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd> or <kbd>></kbd> in open</td></tr>
|
||
<tr><td>Go to definition</td><td><kbd>F12</kbd></td></tr>
|
||
<tr><td>Find references</td><td><kbd>Shift</kbd> + <kbd>F12</kbd></td></tr>
|
||
<tr><td>Build</td><td><kbd>Cmd</kbd> + <kbd>B</kbd></td></tr>
|
||
<tr><td>Fold / Unfold</td><td><kbd>Cmd</kbd> + <kbd>[</kbd> / <kbd>]</kbd></td></tr>
|
||
<tr><td>Close menu / modal</td><td><kbd>Esc</kbd></td></tr>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /#bottom-content -->
|
||
</div>
|
||
|
||
</div><!-- /#body -->
|
||
|
||
<!-- ── CONTEXT MENU ──────────────────────────────────────────────────────────── -->
|
||
<div id="ctx-menu" style="display:none">
|
||
<div class="ctx-item" id="ctx-new-file">New File</div>
|
||
<div class="ctx-item" id="ctx-new-folder">New Folder</div>
|
||
<div class="ctx-sep"></div>
|
||
<div class="ctx-item" id="ctx-rename">Rename</div>
|
||
<div class="ctx-item ctx-danger" id="ctx-delete">Delete</div>
|
||
</div>
|
||
|
||
<!-- ── QUICK OPEN MODAL ──────────────────────────────────────────────────────── -->
|
||
<div id="quick-open-modal" style="display:none">
|
||
<div id="quick-open-box">
|
||
<input id="quick-open-input" placeholder="Open file…" autocomplete="off">
|
||
<div id="quick-open-results"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Hover tooltip -->
|
||
<div class="tooltip" id="tooltip" style="display:none"></div>
|
||
|
||
<!-- Toast notification -->
|
||
<div id="toast"></div>
|
||
|
||
<script type="module">
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Imports — CodeMirror 6 via ESM CDN
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
import { EditorView, basicSetup, highlightActiveLine } from 'https://esm.sh/codemirror@6.0.1';
|
||
import { keymap, drawSelection } from 'https://esm.sh/@codemirror/view@6';
|
||
import { indentWithTab, history, historyKeymap, defaultKeymap } from 'https://esm.sh/@codemirror/commands@6';
|
||
import { StreamLanguage, foldGutter, codeFolding, foldKeymap } from 'https://esm.sh/@codemirror/language@6';
|
||
import { linter, lintGutter } from 'https://esm.sh/@codemirror/lint@6';
|
||
import { EditorState, EditorSelection, StateEffect, StateField, RangeSetBuilder, Transaction } from 'https://esm.sh/@codemirror/state@6';
|
||
// Expose EditorSelection for selectNextOccurrence helper
|
||
window._cm6State = { EditorSelection };
|
||
import { Decoration, MatchDecorator, ViewPlugin } from 'https://esm.sh/@codemirror/view@6';
|
||
import { autocompletion, completionKeymap, snippetCompletion } from 'https://esm.sh/@codemirror/autocomplete@6';
|
||
import { searchKeymap, search, SearchQuery, setSearchQuery, openSearchPanel, closeSearchPanel } from 'https://esm.sh/@codemirror/search@6';
|
||
import { closeBrackets, closeBracketsKeymap } from 'https://esm.sh/@codemirror/autocomplete@6';
|
||
|
||
// Lazily loaded — xterm.js (loaded on first Terminal tab open)
|
||
let xtermLoaded = false;
|
||
let xterm = null;
|
||
let xtermWs = null;
|
||
|
||
// Vim mode state
|
||
let vimEnabled = false;
|
||
let vimExtension = null;
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Engram-lang StreamLanguage definition for CodeMirror 6
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const KEYWORDS2 = new Set(['activate', 'sealed', 'parallel', 'deploy']);
|
||
const KEYWORDS = new Set([
|
||
'let','fn','type','enum','match','return','where','if','else','for','in','while',
|
||
'true','false','protocol','impl','import','from','as','with','retry','times',
|
||
'fallback','reason','trace','requires','to','via','test','seed','assert','target',
|
||
]);
|
||
const BUILTINS = new Set(['Int','Float','String','Bool','Uuid','Void','List','Map','Option','Result','Unit']);
|
||
|
||
const engramLang = StreamLanguage.define({
|
||
name: 'engram',
|
||
token(stream) {
|
||
if (stream.eatSpace()) return null;
|
||
if (stream.match('//')) { stream.skipToEnd(); return 'comment'; }
|
||
if (stream.eat('"')) {
|
||
while (!stream.eol()) {
|
||
if (stream.eat('\\')) { stream.next(); continue; }
|
||
if (stream.eat('"')) break;
|
||
stream.next();
|
||
}
|
||
return 'string';
|
||
}
|
||
if (stream.match(/^-?\d+(\.\d+)?/)) return 'number';
|
||
if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) {
|
||
const word = stream.current();
|
||
if (KEYWORDS2.has(word)) return 'keyword2';
|
||
if (KEYWORDS.has(word)) return 'keyword';
|
||
if (BUILTINS.has(word)) return 'builtin';
|
||
if (/^[A-Z]/.test(word)) return 'type';
|
||
// Function call: identifier followed by (
|
||
if (stream.peek() === '(') return 'function';
|
||
return 'variable';
|
||
}
|
||
if (stream.match('->') || stream.match('=>') || stream.match('::')
|
||
|| stream.match('==') || stream.match('!=') || stream.match('<=') || stream.match('>=')
|
||
|| stream.match('&&') || stream.match('||')) return 'operator';
|
||
if (stream.match(/^[+\-*\/=<>!&|]/)) return 'operator';
|
||
if (stream.match(/^[{}()\[\],.;:]/)) return 'punctuation';
|
||
stream.next();
|
||
return null;
|
||
},
|
||
startState() { return {}; },
|
||
copyState(s) { return { ...s }; },
|
||
blankLine() {},
|
||
indent() { return -1; },
|
||
languageData: { commentTokens: { line: '//' } },
|
||
});
|
||
|
||
function engramTheme() {
|
||
return EditorView.theme({
|
||
'&': { backgroundColor: 'var(--bg)', color: 'var(--text)' },
|
||
'.cm-content': { caretColor: 'var(--accent)' },
|
||
'.cm-cursor': { borderLeftColor: 'var(--accent)' },
|
||
'.cm-gutters': { background: 'var(--bg2)', borderRight: '1px solid var(--border)', color: 'var(--text3)' },
|
||
'.cm-activeLineGutter': { background: 'var(--bg3)' },
|
||
'.cm-activeLine': { background: 'rgba(56,189,248,0.04)' },
|
||
'.cm-selectionBackground': { background: 'rgba(56,189,248,0.15)' },
|
||
'.cm-matchingBracket': { background: 'rgba(56,189,248,0.12)', borderRadius: '2px' },
|
||
'.tok-comment': { color: 'var(--text3)', fontStyle: 'italic' },
|
||
'.tok-string': { color: 'var(--green)' },
|
||
'.tok-number': { color: 'var(--orange)' },
|
||
'.tok-keyword': { color: 'var(--accent)' },
|
||
'.tok-keyword2': { color: 'var(--accent)', fontStyle: 'italic', fontWeight: '600' },
|
||
'.tok-type': { color: 'var(--purple)' },
|
||
'.tok-builtin': { color: 'var(--purple)', opacity: '0.8' },
|
||
'.tok-operator': { color: 'var(--text2)' },
|
||
'.tok-punctuation': { color: 'var(--text3)' },
|
||
'.tok-variable': { color: 'var(--text)' },
|
||
'.tok-function': { color: 'var(--yellow)' },
|
||
'.tok-def': { color: 'var(--accent)', fontWeight: '600' },
|
||
'.tok-atom': { color: 'var(--orange)' },
|
||
}, { dark: true });
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Engram-lang Snippets
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const ENGRAM_SNIPPETS = [
|
||
snippetCompletion('fn ${name}(${params}) -> ${ReturnType} {\n\t${body}\n}', { label: 'fn', detail: 'function', type: 'keyword' }),
|
||
snippetCompletion('type ${Name} {\n\t${field}: ${Type},\n}', { label: 'type', detail: 'struct type', type: 'keyword' }),
|
||
snippetCompletion('enum ${Name} {\n\t${Variant},\n}', { label: 'enum', detail: 'enum type', type: 'keyword' }),
|
||
snippetCompletion('match ${expr} {\n\t${pattern} => ${body},\n}', { label: 'match', detail: 'match expression', type: 'keyword' }),
|
||
snippetCompletion('let ${name}: ${Type} = ${value};', { label: 'let', detail: 'binding', type: 'keyword' }),
|
||
snippetCompletion('protocol ${Name} {\n\tfn ${method}(${self}) -> ${Ret};\n}', { label: 'protocol', detail: 'protocol def', type: 'keyword' }),
|
||
snippetCompletion('impl ${Protocol} for ${Type} {\n\t${body}\n}', { label: 'impl', detail: 'impl block', type: 'keyword' }),
|
||
snippetCompletion('if ${cond} {\n\t${body}\n}', { label: 'if', detail: 'if expression', type: 'keyword' }),
|
||
snippetCompletion('for ${item} in ${iter} {\n\t${body}\n}', { label: 'for', detail: 'for loop', type: 'keyword' }),
|
||
snippetCompletion('test "${description}" {\n\t${body}\n}', { label: 'test', detail: 'test block', type: 'keyword' }),
|
||
snippetCompletion('seed {\n\t${body}\n}', { label: 'seed', detail: 'seed block', type: 'keyword' }),
|
||
snippetCompletion('assert ${expr};', { label: 'assert', detail: 'assertion', type: 'keyword' }),
|
||
snippetCompletion('activate ${TypeName} where "${query}"', { label: 'activate', detail: 'activate query', type: 'keyword' }),
|
||
snippetCompletion('parallel {\n\t${task1},\n\t${task2},\n}', { label: 'parallel', detail: 'parallel block', type: 'keyword' }),
|
||
snippetCompletion('deploy ${service} to ${target} via ${method};', { label: 'deploy', detail: 'deploy statement', type: 'keyword' }),
|
||
snippetCompletion('import ${symbol} from "${module}";', { label: 'import', detail: 'import statement', type: 'keyword' }),
|
||
snippetCompletion('with ${resource} as ${name} {\n\t${body}\n}', { label: 'with', detail: 'with block', type: 'keyword' }),
|
||
snippetCompletion('retry ${times} times {\n\t${body}\n} fallback {\n\t${fallback}\n}', { label: 'retry', detail: 'retry/fallback', type: 'keyword' }),
|
||
snippetCompletion('reason "${goal}" {\n\t${context}\n}', { label: 'reason', detail: 'reasoning block', type: 'keyword' }),
|
||
snippetCompletion('trace "${label}" {\n\t${body}\n}', { label: 'trace', detail: 'trace block', type: 'keyword' }),
|
||
];
|
||
|
||
function engramSnippetSource(context) {
|
||
const word = context.matchBefore(/\w*/);
|
||
if (!word || (word.from === word.to && !context.explicit)) return null;
|
||
const q = word.text.toLowerCase();
|
||
const matches = ENGRAM_SNIPPETS.filter(s => s.label.startsWith(q));
|
||
if (!matches.length) return null;
|
||
return { from: word.from, options: matches };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Word highlight — highlights all occurrences of the word under cursor
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const wordHighlightMark = Decoration.mark({ class: 'cm-wordHighlight' });
|
||
|
||
const wordHighlightPlugin = ViewPlugin.fromClass(class {
|
||
decorations;
|
||
constructor(view) { this.decorations = this.buildDecorations(view); }
|
||
update(update) {
|
||
if (update.selectionSet || update.docChanged) {
|
||
this.decorations = this.buildDecorations(update.view);
|
||
}
|
||
}
|
||
buildDecorations(view) {
|
||
const sel = view.state.selection.main;
|
||
// Only highlight when cursor is inside a word and no explicit selection
|
||
if (!sel.empty) return Decoration.none;
|
||
const doc = view.state.doc;
|
||
const pos = sel.head;
|
||
const line = doc.lineAt(pos);
|
||
const text = line.text;
|
||
const linePos = pos - line.from;
|
||
// Extract word at cursor
|
||
let start = linePos, end = linePos;
|
||
while (start > 0 && /\w/.test(text[start - 1])) start--;
|
||
while (end < text.length && /\w/.test(text[end])) end++;
|
||
const word = text.slice(start, end);
|
||
if (!word || word.length < 2) return Decoration.none;
|
||
|
||
const builder = new RangeSetBuilder();
|
||
const fullText = doc.toString();
|
||
const regex = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
||
let match;
|
||
while ((match = regex.exec(fullText)) !== null) {
|
||
const from = match.index, to = from + word.length;
|
||
// Don't highlight the cursor word itself (it's already highlighted by activeLine)
|
||
if (from <= pos && to >= pos) continue;
|
||
builder.add(from, to, wordHighlightMark);
|
||
}
|
||
return builder.finish();
|
||
}
|
||
}, { decorations: v => v.decorations });
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Server-side Snippets (from /api/completions/snippet)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
let serverSnippets = null;
|
||
|
||
async function loadServerSnippets() {
|
||
try {
|
||
const resp = await fetch('/api/completions/snippet');
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
serverSnippets = data.map(s =>
|
||
snippetCompletion(s.body.replace(/\$\{(\d+):([^}]+)\}/g, '${$2}'), {
|
||
label: s.label,
|
||
detail: s.description,
|
||
type: 'keyword',
|
||
})
|
||
);
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
function serverSnippetSource(context) {
|
||
if (!serverSnippets) return null;
|
||
const word = context.matchBefore(/\w*/);
|
||
if (!word || (word.from === word.to && !context.explicit)) return null;
|
||
const q = word.text.toLowerCase();
|
||
const matches = serverSnippets.filter(s => s.label.startsWith(q));
|
||
if (!matches.length) return null;
|
||
return { from: word.from, options: matches };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// LSP Completions
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function elComplete(context) {
|
||
// Trigger on word chars or explicit invocation
|
||
const word = context.matchBefore(/\w*/);
|
||
if (!word && !context.explicit) return null;
|
||
const source = context.state.doc.toString();
|
||
const pos = context.pos;
|
||
try {
|
||
const resp = await fetch(`/api/lsp/complete?source=${encodeURIComponent(source)}&pos=${pos}`);
|
||
if (!resp.ok) return null;
|
||
const completions = await resp.json();
|
||
if (!completions || !completions.length) return null;
|
||
|
||
// Detect if cursor is in an activate...where "..." context for semantic hint
|
||
const lineText = context.state.doc.lineAt(pos).text;
|
||
const inActivateStr = /activate\s+\w+\s+where\s+"[^"]*/.test(lineText.slice(0, pos - context.state.doc.lineAt(pos).from));
|
||
|
||
return {
|
||
from: word ? word.from : pos,
|
||
options: completions.map(c => {
|
||
const isSemantic = (c.score || 0) >= 0.8 || c.kind === 'keyword2';
|
||
return {
|
||
label: c.label,
|
||
type: c.kind === 'keyword' || c.kind === 'keyword2' ? 'keyword' : c.kind === 'type' ? 'type' : 'variable',
|
||
detail: isSemantic
|
||
? `⟁ semantic ${c.detail || ''}`
|
||
: c.detail || '',
|
||
info: c.documentation || '',
|
||
boost: isSemantic ? 10 : 0,
|
||
};
|
||
}),
|
||
};
|
||
} catch { return null; }
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// IDE State — Tabs
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
let tabs = []; // [{id, path, name, content, unsaved}]
|
||
let activeTabId = null;
|
||
let nextTabId = 1;
|
||
|
||
function openTab(path, name, content) {
|
||
// Check if already open
|
||
const existing = tabs.find(t => t.path === path);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return;
|
||
}
|
||
const id = nextTabId++;
|
||
tabs.push({ id, path, name, content, unsaved: false });
|
||
activeTabId = id;
|
||
renderTabs();
|
||
loadTabIntoEditor(id);
|
||
updateBreadcrumb(path);
|
||
}
|
||
|
||
function closeTab(id) {
|
||
const tab = tabs.find(t => t.id === id);
|
||
if (!tab) return;
|
||
if (tab.unsaved) {
|
||
if (!confirm(`"${tab.name}" has unsaved changes. Close anyway?`)) return;
|
||
}
|
||
const idx = tabs.indexOf(tab);
|
||
tabs = tabs.filter(t => t.id !== id);
|
||
if (activeTabId === id) {
|
||
// Switch to adjacent tab
|
||
if (tabs.length > 0) {
|
||
const newIdx = Math.min(idx, tabs.length - 1);
|
||
switchTab(tabs[newIdx].id);
|
||
} else {
|
||
activeTabId = null;
|
||
setEditorContent('');
|
||
updateBreadcrumb(null);
|
||
updateStatusFile(null);
|
||
}
|
||
}
|
||
renderTabs();
|
||
}
|
||
|
||
function switchTab(id) {
|
||
// Save current editor content to current tab before switching
|
||
if (activeTabId !== null) {
|
||
const current = tabs.find(t => t.id === activeTabId);
|
||
if (current) {
|
||
current.content = editor.state.doc.toString();
|
||
}
|
||
}
|
||
activeTabId = id;
|
||
renderTabs();
|
||
loadTabIntoEditor(id);
|
||
const tab = tabs.find(t => t.id === id);
|
||
if (tab) {
|
||
updateBreadcrumb(tab.path);
|
||
updateStatusFile(tab.path);
|
||
highlightFileInTree(tab.path);
|
||
}
|
||
}
|
||
|
||
function renderTabs() {
|
||
const container = document.getElementById('editor-tabs-scroll');
|
||
container.innerHTML = '';
|
||
for (const tab of tabs) {
|
||
const el = document.createElement('div');
|
||
el.className = 'editor-tab' + (tab.id === activeTabId ? ' active' : '') + (tab.unsaved ? ' unsaved' : '');
|
||
el.dataset.tabId = tab.id;
|
||
|
||
const dot = document.createElement('div');
|
||
dot.className = 'dot';
|
||
|
||
const nameSpan = document.createElement('span');
|
||
nameSpan.textContent = tab.name;
|
||
|
||
const close = document.createElement('div');
|
||
close.className = 'tab-close';
|
||
close.textContent = '×';
|
||
close.title = 'Close (middle-click)';
|
||
close.addEventListener('click', e => { e.stopPropagation(); closeTab(tab.id); });
|
||
|
||
el.appendChild(dot);
|
||
el.appendChild(nameSpan);
|
||
el.appendChild(close);
|
||
el.addEventListener('click', () => switchTab(tab.id));
|
||
// Scroll active tab into view
|
||
if (tab.id === activeTabId) {
|
||
setTimeout(() => el.scrollIntoView({ block: 'nearest', inline: 'nearest' }), 10);
|
||
}
|
||
container.appendChild(el);
|
||
}
|
||
renderTabOverflow();
|
||
}
|
||
|
||
function renderTabOverflow() {
|
||
const menu = document.getElementById('tab-overflow-menu');
|
||
menu.innerHTML = tabs.map(tab =>
|
||
`<div class="tab-overflow-item${tab.id === activeTabId ? ' active-tab' : ''}" data-tab-id="${tab.id}">
|
||
${tab.unsaved ? '<span style="color:var(--orange);margin-right:2px">●</span>' : ''}
|
||
${escapeHtml(tab.name)}
|
||
</div>`
|
||
).join('') || '<div style="color:var(--text3);padding:8px 14px;font-size:11px;">No open tabs</div>';
|
||
|
||
menu.querySelectorAll('.tab-overflow-item[data-tab-id]').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
switchTab(parseInt(el.dataset.tabId, 10));
|
||
menu.style.display = 'none';
|
||
});
|
||
});
|
||
}
|
||
|
||
// Tab overflow toggle
|
||
const tabOverflowBtn = document.getElementById('tab-overflow-btn');
|
||
const tabOverflowMenu = document.getElementById('tab-overflow-menu');
|
||
tabOverflowBtn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
renderTabOverflow();
|
||
tabOverflowMenu.style.display = tabOverflowMenu.style.display === 'none' ? 'block' : 'none';
|
||
});
|
||
document.addEventListener('click', () => {
|
||
tabOverflowMenu.style.display = 'none';
|
||
});
|
||
|
||
function loadTabIntoEditor(id) {
|
||
const tab = tabs.find(t => t.id === id);
|
||
if (!tab) return;
|
||
setEditorContent(tab.content);
|
||
// Restore unsaved state visual
|
||
const tabEl = document.querySelector(`[data-tab-id="${id}"]`);
|
||
if (tabEl) tabEl.classList.toggle('unsaved', tab.unsaved);
|
||
}
|
||
|
||
function markActiveTabUnsaved(unsaved) {
|
||
const tab = tabs.find(t => t.id === activeTabId);
|
||
if (!tab) return;
|
||
tab.unsaved = unsaved;
|
||
const tabEl = document.querySelector(`[data-tab-id="${tab.id}"]`);
|
||
if (tabEl) tabEl.classList.toggle('unsaved', unsaved);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Breadcrumb
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
function updateBreadcrumb(path) {
|
||
const bc = document.getElementById('breadcrumb');
|
||
if (!path) {
|
||
bc.innerHTML = '<span style="color:var(--text3)">no file open</span>';
|
||
return;
|
||
}
|
||
const parts = path.split('/');
|
||
bc.innerHTML = parts.map((part, i) => {
|
||
if (i === parts.length - 1) {
|
||
return `<span class="crumb-current">${escapeHtml(part)}</span>`;
|
||
}
|
||
const dirPath = parts.slice(0, i + 1).join('/');
|
||
return `<span class="crumb" data-path="${escapeHtml(dirPath)}">${escapeHtml(part)}</span><span class="crumb-sep"> / </span>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Editor setup
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
let debounceTimer = null;
|
||
|
||
const lspLinter = linter(async (view) => {
|
||
const source = view.state.doc.toString();
|
||
if (!source.trim()) return [];
|
||
try {
|
||
const params = new URLSearchParams({ source });
|
||
const resp = await fetch(`/api/lsp/errors?${params}`);
|
||
if (!resp.ok) return [];
|
||
const diags = await resp.json();
|
||
updateProblemsPanel(diags);
|
||
return diags.map(d => {
|
||
let from = 0;
|
||
let to = Math.min(source.length, 1);
|
||
// Use line/col if available for accurate underlines
|
||
if (d.line) {
|
||
try {
|
||
const lineInfo = view.state.doc.line(d.line);
|
||
const col = Math.max(0, (d.col || 1) - 1);
|
||
from = lineInfo.from + col;
|
||
// Underline to end of word or end of line
|
||
const lineText = lineInfo.text;
|
||
let endCol = col;
|
||
while (endCol < lineText.length && /\w/.test(lineText[endCol])) endCol++;
|
||
to = lineInfo.from + Math.max(endCol, col + 1);
|
||
} catch {}
|
||
}
|
||
return {
|
||
from,
|
||
to,
|
||
severity: d.severity === 'error' ? 'error' : 'warning',
|
||
message: d.message,
|
||
};
|
||
});
|
||
} catch { return []; }
|
||
}, { delay: 600 });
|
||
|
||
const editor = new EditorView({
|
||
parent: document.getElementById('cm-editor'),
|
||
extensions: [
|
||
basicSetup,
|
||
keymap.of([
|
||
indentWithTab,
|
||
...completionKeymap,
|
||
...closeBracketsKeymap,
|
||
...foldKeymap,
|
||
...searchKeymap,
|
||
]),
|
||
engramLang,
|
||
engramTheme(),
|
||
lspLinter,
|
||
lintGutter(),
|
||
foldGutter(),
|
||
codeFolding(),
|
||
closeBrackets(),
|
||
search({ top: false }),
|
||
autocompletion({ override: [elComplete, engramSnippetSource, serverSnippetSource] }),
|
||
wordHighlightPlugin,
|
||
EditorView.updateListener.of(update => {
|
||
if (update.docChanged) {
|
||
markActiveTabUnsaved(true);
|
||
// Update current tab content cache
|
||
const tab = tabs.find(t => t.id === activeTabId);
|
||
if (tab) tab.content = update.state.doc.toString();
|
||
|
||
clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(() => {
|
||
const src = update.state.doc.toString();
|
||
refreshTypeGraph(src);
|
||
updateActivationHighlights(src);
|
||
// Refresh outline if panel visible
|
||
const outlinePanel = document.getElementById('panel-outline');
|
||
if (outlinePanel && outlinePanel.classList.contains('active')) {
|
||
refreshOutline();
|
||
}
|
||
}, 800);
|
||
}
|
||
|
||
// Activation preview — debounced on cursor/doc change
|
||
if (update.selectionSet || update.docChanged) {
|
||
clearTimeout(activationPreviewTimer);
|
||
activationPreviewTimer = setTimeout(() => {
|
||
updateActivationPreview(update.view);
|
||
}, 300);
|
||
}
|
||
// Update cursor position + selection in status bar
|
||
if (update.selectionSet || update.docChanged) {
|
||
const sel = update.state.selection.main;
|
||
const line = update.state.doc.lineAt(sel.head);
|
||
const col = sel.head - line.from + 1;
|
||
document.getElementById('status-pos').textContent = `Ln ${line.number}, Col ${col}`;
|
||
|
||
// Selection info
|
||
const selEl = document.getElementById('status-sel');
|
||
const selSep = document.getElementById('status-sel-sep');
|
||
if (!sel.empty) {
|
||
const selLen = sel.to - sel.from;
|
||
const selLines = update.state.doc.lineAt(sel.to).number - update.state.doc.lineAt(sel.from).number + 1;
|
||
selEl.textContent = selLines > 1 ? `${selLen} chars (${selLines} lines)` : `${selLen} chars`;
|
||
selEl.style.display = '';
|
||
selSep.style.display = '';
|
||
} else {
|
||
selEl.style.display = 'none';
|
||
selSep.style.display = 'none';
|
||
}
|
||
|
||
if (update.docChanged) {
|
||
document.getElementById('status-lines').textContent = `${update.state.doc.lines} lines`;
|
||
}
|
||
}
|
||
}),
|
||
EditorView.domEventHandlers({
|
||
keydown(e) {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||
e.preventDefault();
|
||
saveCurrentFile();
|
||
return true;
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'F') {
|
||
e.preventDefault();
|
||
switchBottomTab('search');
|
||
document.getElementById('search-input').focus();
|
||
return true;
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
|
||
e.preventDefault();
|
||
runBuildOrRun('build');
|
||
return true;
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
|
||
e.preventDefault();
|
||
showQuickOpen();
|
||
return true;
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'h') {
|
||
e.preventDefault();
|
||
openSearchPanel(editor);
|
||
return true;
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||
e.preventDefault();
|
||
openSearchPanel(editor);
|
||
return true;
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'g') {
|
||
e.preventDefault();
|
||
goToLine();
|
||
return true;
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
|
||
e.preventDefault();
|
||
runBuildOrRun('run');
|
||
return true;
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'K') {
|
||
// Delete current line
|
||
e.preventDefault();
|
||
const sel = editor.state.selection.main;
|
||
const line = editor.state.doc.lineAt(sel.head);
|
||
const from = line.from;
|
||
const to = line.to < editor.state.doc.length ? line.to + 1 : line.to;
|
||
editor.dispatch({ changes: { from, to, insert: '' } });
|
||
return true;
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||
// Toggle line comment
|
||
e.preventDefault();
|
||
toggleLineComment();
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
mousemove(e, view) {
|
||
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY });
|
||
if (pos != null) showHoverTooltip(e, view, pos);
|
||
},
|
||
mouseleave() { hideTooltip(); },
|
||
blur() { activationPreview.classList.remove('visible'); },
|
||
}),
|
||
],
|
||
});
|
||
|
||
function setEditorContent(text) {
|
||
editor.dispatch({
|
||
changes: { from: 0, to: editor.state.doc.length, insert: text },
|
||
});
|
||
}
|
||
|
||
function updateStatusFile(path) {
|
||
document.getElementById('status-file').textContent = path ? path.split('/').pop() : 'no file';
|
||
// Update language indicator
|
||
const ext = path ? path.split('.').pop().toLowerCase() : '';
|
||
const lang = ext === 'el' ? 'engram' : ext === 'rs' ? 'rust' : ext === 'toml' ? 'toml' : ext === 'json' ? 'json' : ext === 'md' ? 'markdown' : ext || 'text';
|
||
document.getElementById('status-lang').textContent = lang;
|
||
// Update line count
|
||
document.getElementById('status-lines').textContent = `${editor.state.doc.lines} lines`;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Hover tooltip
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const tooltip = document.getElementById('tooltip');
|
||
let hoverTimer = null;
|
||
|
||
async function showHoverTooltip(e, view, pos) {
|
||
clearTimeout(hoverTimer);
|
||
hoverTimer = setTimeout(async () => {
|
||
const source = view.state.doc.toString();
|
||
try {
|
||
const params = new URLSearchParams({ source, pos });
|
||
const resp = await fetch(`/api/lsp/hover?${params}`);
|
||
if (!resp.ok) { hideTooltip(); return; }
|
||
const info = await resp.json();
|
||
if (!info) { hideTooltip(); return; }
|
||
tooltip.innerHTML = `<span class="type-label">${escapeHtml(info.type_name)}</span>\n${escapeHtml(info.documentation)}`;
|
||
tooltip.style.display = 'block';
|
||
tooltip.style.left = (e.clientX + 16) + 'px';
|
||
tooltip.style.top = (e.clientY + 8) + 'px';
|
||
} catch { hideTooltip(); }
|
||
}, 400);
|
||
}
|
||
|
||
function hideTooltip() {
|
||
clearTimeout(hoverTimer);
|
||
tooltip.style.display = 'none';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// File tree (with flat index for quick-open)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
let allFiles = []; // flat list of {path, name} for quick-open
|
||
|
||
async function loadFileTree() {
|
||
try {
|
||
const resp = await fetch('/api/files?path=.');
|
||
if (!resp.ok) throw new Error(await resp.text());
|
||
const entries = await resp.json();
|
||
allFiles = [];
|
||
collectFiles(entries);
|
||
renderFileTree(entries);
|
||
} catch (err) {
|
||
document.getElementById('file-list').innerHTML =
|
||
`<div style="color:var(--red);padding:12px;font-size:11px;">Error: ${escapeHtml(String(err))}</div>`;
|
||
}
|
||
}
|
||
|
||
function collectFiles(entries) {
|
||
for (const e of entries) {
|
||
if (!e.is_dir) allFiles.push({ path: e.path, name: e.name });
|
||
if (e.children) collectFiles(e.children);
|
||
}
|
||
}
|
||
|
||
function renderFileTree(entries, container = null, depth = 0) {
|
||
const list = container || document.getElementById('file-list');
|
||
if (!container) list.innerHTML = '';
|
||
|
||
for (const entry of entries) {
|
||
const item = document.createElement('div');
|
||
item.className = 'file-item' + (entry.is_dir ? ' dir' : '');
|
||
item.dataset.path = entry.path;
|
||
item.style.paddingLeft = (12 + depth * 14) + 'px';
|
||
|
||
const icon = document.createElement('span');
|
||
icon.className = 'icon';
|
||
icon.textContent = entry.is_dir ? '▸' : '·';
|
||
item.appendChild(icon);
|
||
|
||
const name = document.createElement('span');
|
||
name.textContent = entry.name;
|
||
item.appendChild(name);
|
||
|
||
if (entry.is_dir) {
|
||
item.addEventListener('click', () => {
|
||
const isOpen = item.dataset.open === '1';
|
||
item.dataset.open = isOpen ? '0' : '1';
|
||
icon.textContent = isOpen ? '▸' : '▾';
|
||
const childContainer = item.nextElementSibling;
|
||
if (childContainer && childContainer.dataset.children) {
|
||
childContainer.style.display = isOpen ? 'none' : '';
|
||
}
|
||
});
|
||
|
||
list.appendChild(item);
|
||
const childDiv = document.createElement('div');
|
||
childDiv.dataset.children = '1';
|
||
if (entry.children && entry.children.length > 0) {
|
||
renderFileTree(entry.children, childDiv, depth + 1);
|
||
}
|
||
list.appendChild(childDiv);
|
||
} else {
|
||
item.addEventListener('click', () => openFileFromTree(entry.path, entry.name));
|
||
item.addEventListener('contextmenu', e => {
|
||
e.preventDefault();
|
||
showCtxMenu(e.clientX, e.clientY, entry.path, false);
|
||
});
|
||
list.appendChild(item);
|
||
}
|
||
|
||
if (entry.is_dir) {
|
||
item.addEventListener('contextmenu', e => {
|
||
e.preventDefault();
|
||
showCtxMenu(e.clientX, e.clientY, entry.path, true);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
function highlightFileInTree(path) {
|
||
document.querySelectorAll('.file-item').forEach(el => el.classList.remove('active'));
|
||
document.querySelector(`.file-item[data-path="${CSS.escape(path)}"]`)?.classList.add('active');
|
||
}
|
||
|
||
async function openFileFromTree(path, name) {
|
||
highlightFileInTree(path);
|
||
// Check if already in tabs
|
||
const existing = tabs.find(t => t.path === path);
|
||
if (existing) {
|
||
switchTab(existing.id);
|
||
return;
|
||
}
|
||
try {
|
||
const resp = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
|
||
if (!resp.ok) throw new Error(await resp.text());
|
||
const { content } = await resp.json();
|
||
openTab(path, name, content);
|
||
refreshTypeGraph(content);
|
||
updateActivationHighlights(content);
|
||
} catch (err) {
|
||
appendBuildLine('error', `Failed to open ${path}: ${err}`);
|
||
switchBottomTab('build');
|
||
}
|
||
}
|
||
|
||
async function saveCurrentFile() {
|
||
const tab = tabs.find(t => t.id === activeTabId);
|
||
if (!tab) return;
|
||
// Format on save if enabled
|
||
if (formatOnSave) await formatCurrentFile();
|
||
const content = editor.state.doc.toString();
|
||
tab.content = content;
|
||
try {
|
||
const resp = await fetch('/api/file', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: tab.path, content }),
|
||
});
|
||
if (!resp.ok) throw new Error(await resp.text());
|
||
markActiveTabUnsaved(false);
|
||
showToast(`Saved ${tab.path.split('/').pop()}`);
|
||
} catch (err) {
|
||
appendBuildLine('error', `Save failed: ${err}`);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Context Menu
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const ctxMenu = document.getElementById('ctx-menu');
|
||
let ctxTargetPath = null;
|
||
let ctxTargetIsDir = false;
|
||
|
||
function showCtxMenu(x, y, path, isDir) {
|
||
ctxTargetPath = path;
|
||
ctxTargetIsDir = isDir;
|
||
ctxMenu.style.display = 'block';
|
||
ctxMenu.style.left = x + 'px';
|
||
ctxMenu.style.top = y + 'px';
|
||
|
||
// Adjust if off-screen
|
||
const rect = ctxMenu.getBoundingClientRect();
|
||
if (rect.right > window.innerWidth) ctxMenu.style.left = (window.innerWidth - rect.width - 4) + 'px';
|
||
if (rect.bottom > window.innerHeight) ctxMenu.style.top = (window.innerHeight - rect.height - 4) + 'px';
|
||
}
|
||
|
||
function hideCtxMenu() {
|
||
ctxMenu.style.display = 'none';
|
||
ctxTargetPath = null;
|
||
}
|
||
|
||
document.addEventListener('click', e => {
|
||
if (!ctxMenu.contains(e.target)) hideCtxMenu();
|
||
});
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') {
|
||
hideCtxMenu();
|
||
hideQuickOpen();
|
||
const themeMenu = document.getElementById('theme-menu');
|
||
if (themeMenu.style.display !== 'none') themeMenu.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
document.getElementById('ctx-new-file').addEventListener('click', async () => {
|
||
if (!ctxTargetPath) return;
|
||
hideCtxMenu();
|
||
const name = prompt('New file name:');
|
||
if (!name) return;
|
||
const dir = ctxTargetIsDir ? ctxTargetPath : ctxTargetPath.split('/').slice(0, -1).join('/');
|
||
const newPath = dir ? `${dir}/${name}` : name;
|
||
try {
|
||
await fetch('/api/file', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: newPath, content: '' }),
|
||
});
|
||
await loadFileTree();
|
||
await openFileFromTree(newPath, name);
|
||
} catch (err) { alert(`Error: ${err}`); }
|
||
});
|
||
|
||
document.getElementById('ctx-new-folder').addEventListener('click', async () => {
|
||
if (!ctxTargetPath) return;
|
||
hideCtxMenu();
|
||
const name = prompt('New folder name:');
|
||
if (!name) return;
|
||
const dir = ctxTargetIsDir ? ctxTargetPath : ctxTargetPath.split('/').slice(0, -1).join('/');
|
||
const newPath = dir ? `${dir}/${name}` : name;
|
||
try {
|
||
await fetch('/api/files/mkdir', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: newPath }),
|
||
});
|
||
await loadFileTree();
|
||
} catch (err) { alert(`Error: ${err}`); }
|
||
});
|
||
|
||
document.getElementById('ctx-rename').addEventListener('click', async () => {
|
||
if (!ctxTargetPath) return;
|
||
const oldName = ctxTargetPath.split('/').pop();
|
||
hideCtxMenu();
|
||
const newName = prompt(`Rename "${oldName}" to:`, oldName);
|
||
if (!newName || newName === oldName) return;
|
||
const dir = ctxTargetPath.split('/').slice(0, -1).join('/');
|
||
const newPath = dir ? `${dir}/${newName}` : newName;
|
||
try {
|
||
await fetch('/api/files/rename', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ from: ctxTargetPath, to: newPath }),
|
||
});
|
||
// Update open tabs that referenced the old path
|
||
const oldPath = ctxTargetPath;
|
||
for (const tab of tabs) {
|
||
if (tab.path === oldPath) {
|
||
tab.path = newPath;
|
||
tab.name = newName;
|
||
}
|
||
}
|
||
renderTabs();
|
||
await loadFileTree();
|
||
} catch (err) { alert(`Error: ${err}`); }
|
||
});
|
||
|
||
document.getElementById('ctx-delete').addEventListener('click', async () => {
|
||
if (!ctxTargetPath) return;
|
||
const targetPath = ctxTargetPath;
|
||
hideCtxMenu();
|
||
if (!confirm(`Delete "${targetPath}"?`)) return;
|
||
try {
|
||
await fetch(`/api/file?path=${encodeURIComponent(targetPath)}`, { method: 'DELETE' });
|
||
// Close any tabs referencing deleted path
|
||
const toClose = tabs.filter(t => t.path === targetPath || t.path.startsWith(targetPath + '/')).map(t => t.id);
|
||
for (const id of toClose) {
|
||
tabs = tabs.filter(t => t.id !== id);
|
||
}
|
||
if (toClose.includes(activeTabId)) {
|
||
activeTabId = tabs.length > 0 ? tabs[tabs.length - 1].id : null;
|
||
if (activeTabId !== null) loadTabIntoEditor(activeTabId);
|
||
else { setEditorContent(''); updateBreadcrumb(null); updateStatusFile(null); }
|
||
}
|
||
renderTabs();
|
||
await loadFileTree();
|
||
} catch (err) { alert(`Error: ${err}`); }
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Quick Open (Cmd+P)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const quickOpenModal = document.getElementById('quick-open-modal');
|
||
const quickOpenInput = document.getElementById('quick-open-input');
|
||
const quickOpenResults = document.getElementById('quick-open-results');
|
||
let qoSelectedIdx = 0;
|
||
|
||
// IDE commands for command palette (> prefix)
|
||
const IDE_COMMANDS = [
|
||
{ label: 'Build Project', icon: '⚙', action: () => runBuildOrRun('build') },
|
||
{ label: 'Run Project', icon: '▶', action: () => runBuildOrRun('run') },
|
||
{ label: 'Check Errors', icon: '✓', action: () => document.getElementById('btn-check').click() },
|
||
{ label: 'Format File', icon: '⌘', action: () => formatCurrentFile() },
|
||
{ label: 'Open Search Panel', icon: '🔍', action: () => { switchBottomTab('search'); document.getElementById('search-input').focus(); } },
|
||
{ label: 'Open Terminal', icon: '>', action: () => switchBottomTab('terminal') },
|
||
{ label: 'Open Outline', icon: '≡', action: () => switchBottomTab('outline') },
|
||
{ label: 'Open Plugins', icon: '⚡', action: () => switchBottomTab('plugins') },
|
||
{ label: 'Open Settings', icon: '⚙', action: () => switchBottomTab('settings') },
|
||
{ label: 'Theme: Dark', icon: '◐', action: () => applyTheme('dark') },
|
||
{ label: 'Theme: Light', icon: '◑', action: () => applyTheme('light') },
|
||
{ label: 'Theme: Neuron', icon: '◈', action: () => applyTheme('neuron') },
|
||
{ label: 'Theme: High Contrast', icon: '◉', action: () => applyTheme('high-contrast') },
|
||
{ label: 'Reload File Tree', icon: '↺', action: () => loadFileTree().then(loadGitStatus) },
|
||
{ label: 'Find & Replace', icon: '⇄', action: () => openSearchPanel(editor) },
|
||
{ label: 'Save File', icon: '💾', action: () => saveCurrentFile() },
|
||
{ label: 'Close Active Tab', icon: '×', action: () => { if (activeTabId) closeTab(activeTabId); } },
|
||
{ label: 'Git Status Refresh', icon: '⎇', action: () => loadGitStatus() },
|
||
{ label: 'Open Neuron Panel', icon: '⟁', action: () => { if (!neuronPanelVisible) toggleNeuronPanel(); neuronInput.focus(); } },
|
||
{ label: 'Open Knowledge Graph', icon: '◈', action: () => { switchBottomTab('knowledge'); } },
|
||
{ label: 'Neuron: Explain File', icon: '⟁', action: () => sendNeuronMessage('Explain this file: what does it do and how is it structured?') },
|
||
{ label: 'Neuron: Suggest Activations', icon: '⟁', action: () => sendNeuronMessage('What activate queries would be useful for the types in this file?') },
|
||
{ label: 'Go To Line', icon: '→', action: () => goToLine() },
|
||
{ label: 'Toggle Word Wrap', icon: '↵', action: () => { wordWrapCheck.checked = !wordWrapCheck.checked; wordWrapCheck.dispatchEvent(new Event('change')); } },
|
||
{ label: 'Toggle Minimap', icon: '▦', action: () => { const mm = document.getElementById('minimap-container'); mm.style.display = mm.style.display === 'none' ? '' : 'none'; } },
|
||
{ label: 'Toggle File Tree', icon: '⊞', action: () => toggleFileTree() },
|
||
{ label: 'Toggle Bottom Panel', icon: '⬇', action: () => toggleBottomPanel() },
|
||
{ label: 'Increase Font Size', icon: 'A+', action: () => { const s = Math.min(24, parseInt(fontSizeSlider.value) + 1); fontSizeSlider.value = s; applySetting('fontSize', s); localStorage.setItem('el-ide-setting-fontSize', s); } },
|
||
{ label: 'Decrease Font Size', icon: 'A-', action: () => { const s = Math.max(10, parseInt(fontSizeSlider.value) - 1); fontSizeSlider.value = s; applySetting('fontSize', s); localStorage.setItem('el-ide-setting-fontSize', s); } },
|
||
{ label: 'Select Next Occurrence', icon: '▸▸', action: () => selectNextOccurrence() },
|
||
{ label: 'New File', icon: '+', action: () => { const name = prompt('New file name:'); if (name) createNewFile(name); } },
|
||
];
|
||
|
||
function fuzzyScore(query, text) {
|
||
const q = query.toLowerCase();
|
||
const t = text.toLowerCase();
|
||
if (t.includes(q)) return 2;
|
||
// Simple fuzzy: all query chars present in order
|
||
let qi = 0;
|
||
for (let i = 0; i < t.length && qi < q.length; i++) {
|
||
if (t[i] === q[qi]) qi++;
|
||
}
|
||
return qi === q.length ? 1 : 0;
|
||
}
|
||
|
||
function showQuickOpen() {
|
||
quickOpenModal.style.display = 'flex';
|
||
quickOpenInput.value = '';
|
||
qoSelectedIdx = 0;
|
||
renderQuickOpenResults('');
|
||
quickOpenInput.focus();
|
||
}
|
||
|
||
function hideQuickOpen() {
|
||
quickOpenModal.style.display = 'none';
|
||
}
|
||
|
||
function renderQuickOpenResults(query) {
|
||
// Command mode: starts with >
|
||
if (query.startsWith('>')) {
|
||
const q = query.slice(1).trim().toLowerCase();
|
||
const filtered = q
|
||
? IDE_COMMANDS.filter(c => fuzzyScore(q, c.label) > 0)
|
||
.sort((a, b) => fuzzyScore(q, b.label) - fuzzyScore(q, a.label))
|
||
: IDE_COMMANDS;
|
||
|
||
quickOpenResults.innerHTML =
|
||
`<div class="qo-hint">Commands — press Enter to run</div>` +
|
||
filtered.slice(0, 20).map((c, i) => {
|
||
const hl = q
|
||
? c.label.replace(new RegExp(`(${escapeRegex(q)})`, 'gi'), '<span class="qo-mark">$1</span>')
|
||
: escapeHtml(c.label);
|
||
return `<div class="qo-result${i === qoSelectedIdx ? ' selected' : ''}" data-cmd-idx="${IDE_COMMANDS.indexOf(c)}">
|
||
<span class="qo-icon">${c.icon}</span>
|
||
<span class="qo-name">${hl}</span>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
quickOpenResults.querySelectorAll('.qo-result').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
const idx = parseInt(el.dataset.cmdIdx, 10);
|
||
if (IDE_COMMANDS[idx]) IDE_COMMANDS[idx].action();
|
||
hideQuickOpen();
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
|
||
// File mode
|
||
const q = query.toLowerCase();
|
||
const filtered = q
|
||
? allFiles
|
||
.map(f => ({ f, score: fuzzyScore(q, f.name) + fuzzyScore(q, f.path) * 0.5 }))
|
||
.filter(x => x.score > 0)
|
||
.sort((a, b) => b.score - a.score)
|
||
.map(x => x.f)
|
||
: allFiles.slice(0, 20);
|
||
|
||
quickOpenResults.innerHTML =
|
||
`<div class="qo-hint">Files — type <span style="color:var(--accent)">></span> for commands</div>` +
|
||
filtered.slice(0, 40).map((f, i) => {
|
||
const highlight = q ? f.name.replace(new RegExp(`(${escapeRegex(q)})`, 'gi'), '<span class="qo-mark">$1</span>') : escapeHtml(f.name);
|
||
return `<div class="qo-result${i === qoSelectedIdx ? ' selected' : ''}" data-path="${escapeHtml(f.path)}" data-name="${escapeHtml(f.name)}">
|
||
<span class="qo-icon">·</span>
|
||
<span class="qo-name">${highlight}</span>
|
||
<span class="qo-path">${escapeHtml(f.path.split('/').slice(0, -1).join('/'))}</span>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
quickOpenResults.querySelectorAll('.qo-result').forEach(el => {
|
||
if (el.dataset.path) {
|
||
el.addEventListener('click', () => {
|
||
openFileFromTree(el.dataset.path, el.dataset.name);
|
||
hideQuickOpen();
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
quickOpenInput.addEventListener('input', () => {
|
||
qoSelectedIdx = 0;
|
||
renderQuickOpenResults(quickOpenInput.value);
|
||
});
|
||
|
||
quickOpenInput.addEventListener('keydown', e => {
|
||
const results = quickOpenResults.querySelectorAll('.qo-result');
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
qoSelectedIdx = Math.min(qoSelectedIdx + 1, results.length - 1);
|
||
results.forEach((el, i) => el.classList.toggle('selected', i === qoSelectedIdx));
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
qoSelectedIdx = Math.max(qoSelectedIdx - 1, 0);
|
||
results.forEach((el, i) => el.classList.toggle('selected', i === qoSelectedIdx));
|
||
} else if (e.key === 'Enter') {
|
||
const sel = results[qoSelectedIdx];
|
||
if (sel) {
|
||
if (sel.dataset.cmdIdx !== undefined) {
|
||
const idx = parseInt(sel.dataset.cmdIdx, 10);
|
||
if (IDE_COMMANDS[idx]) IDE_COMMANDS[idx].action();
|
||
hideQuickOpen();
|
||
} else if (sel.dataset.path) {
|
||
openFileFromTree(sel.dataset.path, sel.dataset.name);
|
||
hideQuickOpen();
|
||
}
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
hideQuickOpen();
|
||
}
|
||
});
|
||
|
||
quickOpenModal.addEventListener('click', e => {
|
||
if (e.target === quickOpenModal) hideQuickOpen();
|
||
});
|
||
|
||
// Global keyboard shortcuts
|
||
document.addEventListener('keydown', e => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
|
||
e.preventDefault();
|
||
showQuickOpen();
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
||
e.preventDefault();
|
||
if (activeTabId !== null) closeTab(activeTabId);
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'P') {
|
||
// Command palette with > prefix
|
||
e.preventDefault();
|
||
showQuickOpen();
|
||
setTimeout(() => { quickOpenInput.value = '>'; renderQuickOpenResults('>'); }, 10);
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') {
|
||
e.preventDefault();
|
||
// Cycle tabs
|
||
if (tabs.length < 2) return;
|
||
const idx = tabs.findIndex(t => t.id === activeTabId);
|
||
const next = e.shiftKey ? (idx - 1 + tabs.length) % tabs.length : (idx + 1) % tabs.length;
|
||
switchTab(tabs[next].id);
|
||
}
|
||
// Toggle file tree
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
|
||
e.preventDefault();
|
||
toggleFileTree();
|
||
}
|
||
// Toggle bottom panel
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'j') {
|
||
e.preventDefault();
|
||
toggleBottomPanel();
|
||
}
|
||
// Select next occurrence (Ctrl+D)
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'd' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
selectNextOccurrence();
|
||
}
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Build / Run
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
function clearBuildOutput() {
|
||
document.getElementById('panel-build').innerHTML = '';
|
||
}
|
||
|
||
// Error pattern: "path/to/file.el:12:5: error: ..."
|
||
const ERROR_LOC_RE = /^(.+\.el):(\d+)(?::(\d+))?:/;
|
||
|
||
function appendBuildLine(type, text) {
|
||
const panel = document.getElementById('panel-build');
|
||
const line = document.createElement('span');
|
||
// Colorize error/warning keywords
|
||
const lc = text.toLowerCase();
|
||
let cls = type;
|
||
if (type === 'info' || type === 'output') {
|
||
if (/\berror\b/.test(lc)) cls = 'error';
|
||
else if (/\bwarning\b/.test(lc)) cls = 'warning';
|
||
}
|
||
line.className = `out-line ${cls}`;
|
||
line.textContent = text;
|
||
|
||
// Make error/warning lines clickable if they have a file location
|
||
const match = ERROR_LOC_RE.exec(text);
|
||
if (match && (cls === 'error' || cls === 'warning')) {
|
||
const filePath = match[1];
|
||
const lineNum = parseInt(match[2], 10);
|
||
line.classList.add('clickable');
|
||
line.title = `Open ${filePath}:${lineNum}`;
|
||
line.addEventListener('click', () => {
|
||
const name = filePath.split('/').pop();
|
||
openFileFromTree(filePath, name).then(() => {
|
||
setTimeout(() => {
|
||
try {
|
||
const lineInfo = editor.state.doc.line(lineNum);
|
||
editor.dispatch({
|
||
selection: { anchor: lineInfo.from },
|
||
scrollIntoView: true,
|
||
});
|
||
editor.focus();
|
||
} catch {}
|
||
}, 50);
|
||
});
|
||
});
|
||
}
|
||
|
||
panel.appendChild(line);
|
||
// Keep #bottom-content scrolled
|
||
const bc = document.getElementById('bottom-content');
|
||
bc.scrollTop = bc.scrollHeight;
|
||
}
|
||
|
||
async function runBuildOrRun(action) {
|
||
const tab = tabs.find(t => t.id === activeTabId);
|
||
switchBottomTab('build');
|
||
clearBuildOutput();
|
||
appendBuildLine('info', `Starting ${action}...`);
|
||
|
||
const startTime = Date.now();
|
||
const target = document.getElementById('target-select').value;
|
||
const body = action === 'build'
|
||
? { target, file: tab ? tab.path : undefined }
|
||
: { file: tab ? tab.path : 'src/main.el' };
|
||
|
||
let errorCount = 0;
|
||
let warnCount = 0;
|
||
|
||
try {
|
||
const resp = await fetch(`/api/${action}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop();
|
||
|
||
let eventType = 'info';
|
||
for (const line of lines) {
|
||
if (line.startsWith('event:')) {
|
||
eventType = line.slice(6).trim();
|
||
} else if (line.startsWith('data:')) {
|
||
const data = line.slice(5).trim();
|
||
if (eventType === 'done') {
|
||
const code = parseInt(data, 10);
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
if (code === 0) {
|
||
appendBuildLine('success', `✓ Build succeeded in ${elapsed}s`);
|
||
} else {
|
||
appendBuildLine('error', `✗ ${errorCount} error${errorCount !== 1 ? 's' : ''}, ${warnCount} warning${warnCount !== 1 ? 's' : ''} — exited ${code} (${elapsed}s)`);
|
||
}
|
||
} else {
|
||
if (/\berror\b/i.test(data)) errorCount++;
|
||
if (/\bwarning\b/i.test(data)) warnCount++;
|
||
appendBuildLine(eventType, data);
|
||
}
|
||
eventType = 'info';
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
appendBuildLine('error', `Request failed: ${err}`);
|
||
}
|
||
}
|
||
|
||
document.getElementById('btn-build').addEventListener('click', () => runBuildOrRun('build'));
|
||
document.getElementById('btn-run').addEventListener('click', () => runBuildOrRun('run'));
|
||
document.getElementById('btn-check').addEventListener('click', async () => {
|
||
switchBottomTab('problems');
|
||
const source = editor.state.doc.toString();
|
||
try {
|
||
const params = new URLSearchParams({ source });
|
||
const resp = await fetch(`/api/lsp/errors?${params}`);
|
||
const diags = await resp.json();
|
||
updateProblemsPanel(diags);
|
||
} catch (err) {
|
||
appendBuildLine('error', String(err));
|
||
}
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Problems panel
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
function updateProblemsPanel(diags) {
|
||
const panel = document.getElementById('panel-problems');
|
||
const badge = document.getElementById('problems-badge');
|
||
const errors = diags.filter(d => d.severity === 'error');
|
||
const warnings = diags.filter(d => d.severity === 'warning');
|
||
badge.textContent = errors.length;
|
||
badge.style.display = errors.length > 0 ? '' : 'none';
|
||
|
||
// Update status bar error/warning counts
|
||
const statusErrors = document.getElementById('status-errors');
|
||
const statusWarnings = document.getElementById('status-warnings');
|
||
if (statusErrors) {
|
||
if (errors.length > 0) {
|
||
statusErrors.textContent = `✕ ${errors.length}`;
|
||
statusErrors.style.display = '';
|
||
} else {
|
||
statusErrors.style.display = 'none';
|
||
}
|
||
}
|
||
if (statusWarnings) {
|
||
if (warnings.length > 0) {
|
||
statusWarnings.textContent = `⚠ ${warnings.length}`;
|
||
statusWarnings.style.display = '';
|
||
} else {
|
||
statusWarnings.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
if (diags.length === 0) {
|
||
panel.innerHTML = '<div class="no-problems">No problems detected.</div>';
|
||
return;
|
||
}
|
||
|
||
panel.innerHTML = diags.map(d => `
|
||
<div class="problem-item" data-line="${d.line || 0}">
|
||
<span class="problem-icon ${d.severity}">${d.severity === 'error' ? '●' : '▲'}</span>
|
||
<div class="problem-meta">
|
||
<span class="problem-msg">${escapeHtml(d.message)}</span>
|
||
${d.line ? `<span class="problem-loc">Line ${d.line}${d.col ? ':' + d.col : ''}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
panel.querySelectorAll('.problem-item[data-line]').forEach(el => {
|
||
const lineNum = parseInt(el.dataset.line, 10);
|
||
if (!lineNum) return;
|
||
el.addEventListener('click', () => {
|
||
try {
|
||
const lineInfo = editor.state.doc.line(lineNum);
|
||
editor.dispatch({ selection: { anchor: lineInfo.from }, scrollIntoView: true });
|
||
editor.focus();
|
||
} catch {}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Search panel — with case/word/regex toggles and replace
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
let searchOpts = { matchCase: false, wholeWord: false, useRegex: false };
|
||
|
||
function wireSearchToggle(id, key, shortcut) {
|
||
const btn = document.getElementById(id);
|
||
if (!btn) return;
|
||
btn.addEventListener('click', () => {
|
||
searchOpts[key] = !searchOpts[key];
|
||
btn.classList.toggle('active', searchOpts[key]);
|
||
});
|
||
}
|
||
wireSearchToggle('search-toggle-case', 'matchCase', 'Alt+C');
|
||
wireSearchToggle('search-toggle-word', 'wholeWord', 'Alt+W');
|
||
wireSearchToggle('search-toggle-regex', 'useRegex', 'Alt+R');
|
||
|
||
// Alt+C/W/R keyboard shortcuts in search panel
|
||
document.getElementById('search-input').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') { runSearch(); return; }
|
||
if (e.altKey && e.key.toLowerCase() === 'c') { document.getElementById('search-toggle-case').click(); return; }
|
||
if (e.altKey && e.key.toLowerCase() === 'w') { document.getElementById('search-toggle-word').click(); return; }
|
||
if (e.altKey && e.key.toLowerCase() === 'r') { document.getElementById('search-toggle-regex').click(); return; }
|
||
});
|
||
|
||
document.getElementById('btn-search').addEventListener('click', runSearch);
|
||
|
||
document.getElementById('btn-replace-all').addEventListener('click', async () => {
|
||
const query = document.getElementById('search-input').value.trim();
|
||
const replaceStr = document.getElementById('search-replace-input').value;
|
||
if (!query) return;
|
||
// Replace in currently open file
|
||
if (!editor || activeTabId === null) { showToast('No file open'); return; }
|
||
const tab = tabs.find(t => t.id === activeTabId);
|
||
if (!tab) return;
|
||
const content = editor.state.doc.toString();
|
||
let newContent;
|
||
try {
|
||
if (searchOpts.useRegex) {
|
||
const flags = searchOpts.matchCase ? 'g' : 'gi';
|
||
newContent = content.replace(new RegExp(query, flags), replaceStr);
|
||
} else {
|
||
let pattern = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
if (searchOpts.wholeWord) pattern = `\\b${pattern}\\b`;
|
||
const flags = searchOpts.matchCase ? 'g' : 'gi';
|
||
newContent = content.replace(new RegExp(pattern, flags), replaceStr);
|
||
}
|
||
} catch (err) {
|
||
showToast('Invalid regex: ' + err.message);
|
||
return;
|
||
}
|
||
if (newContent === content) { showToast('No matches'); return; }
|
||
editor.dispatch({ changes: { from: 0, to: content.length, insert: newContent } });
|
||
tab.content = newContent;
|
||
await saveCurrentFile();
|
||
showToast('Replaced all occurrences');
|
||
});
|
||
|
||
async function runSearch() {
|
||
const query = document.getElementById('search-input').value.trim();
|
||
if (!query) return;
|
||
const results = document.getElementById('search-results');
|
||
results.innerHTML = '<div style="color:var(--text3)">Searching…</div>';
|
||
try {
|
||
const params = new URLSearchParams({ query });
|
||
if (searchOpts.matchCase) params.set('case_sensitive', '1');
|
||
if (searchOpts.wholeWord) params.set('whole_word', '1');
|
||
if (searchOpts.useRegex) params.set('regex', '1');
|
||
const resp = await fetch(`/api/search?${params}`);
|
||
if (!resp.ok) throw new Error(await resp.text());
|
||
const data = await resp.json();
|
||
if (data.length === 0) {
|
||
results.innerHTML = '<div style="color:var(--text3);padding:4px 0">No results found.</div>';
|
||
return;
|
||
}
|
||
results.innerHTML = data.map(r => `
|
||
<div class="search-result" data-path="${escapeHtml(r.path)}" data-name="${escapeHtml(r.path.split('/').pop())}" data-line="${r.line}">
|
||
<span class="search-path">${escapeHtml(r.path)}</span>
|
||
<span class="search-loc">:${r.line}</span>
|
||
<span class="search-snippet">${escapeHtml(r.snippet)}</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
results.querySelectorAll('.search-result').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
const path = el.dataset.path;
|
||
const name = el.dataset.name;
|
||
const line = parseInt(el.dataset.line, 10);
|
||
switchBottomTab('search');
|
||
openFileFromTree(path, name).then(() => {
|
||
// Jump to line
|
||
setTimeout(() => {
|
||
try {
|
||
const lineInfo = editor.state.doc.line(line);
|
||
editor.dispatch({
|
||
selection: { anchor: lineInfo.from },
|
||
scrollIntoView: true,
|
||
});
|
||
editor.focus();
|
||
} catch { /* ignore */ }
|
||
}, 50);
|
||
});
|
||
});
|
||
});
|
||
|
||
if (data.length >= 200) {
|
||
const note = document.createElement('div');
|
||
note.style.cssText = 'color:var(--text3);font-size:10px;padding:4px 0;';
|
||
note.textContent = 'Results capped at 200. Refine your query.';
|
||
results.appendChild(note);
|
||
}
|
||
} catch (err) {
|
||
results.innerHTML = `<div style="color:var(--red)">Error: ${escapeHtml(String(err))}</div>`;
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Type Graph — canvas force-directed renderer
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const canvas = document.getElementById('graph-canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
const NODE_COLORS = {
|
||
builtin: '#4a5a6a',
|
||
struct: '#38bdf8',
|
||
enum: '#a78bfa',
|
||
primitive: '#3a4a5a',
|
||
protocol: '#4ade80',
|
||
};
|
||
|
||
const graphState = {
|
||
data: { nodes: [], edges: [] },
|
||
nodes: [],
|
||
dragging: false,
|
||
dragStart: null,
|
||
activatedNodes: new Set(), // node IDs currently "activated" for animation
|
||
activationFrame: 0, // animation frame counter
|
||
animating: false,
|
||
};
|
||
|
||
function nodeColor(kind) {
|
||
return NODE_COLORS[kind] || '#7a8a9a';
|
||
}
|
||
|
||
function resizeCanvas() {
|
||
const rect = canvas.parentElement.getBoundingClientRect();
|
||
canvas.width = rect.width;
|
||
canvas.height = rect.height - 48;
|
||
}
|
||
|
||
async function refreshTypeGraph(source) {
|
||
try {
|
||
const params = new URLSearchParams({ source });
|
||
const resp = await fetch(`/api/type-graph?${params}`);
|
||
if (!resp.ok) return;
|
||
const graph = await resp.json();
|
||
graphState.data = graph;
|
||
initGraphLayout(graph);
|
||
updateActivationHighlights(source);
|
||
drawGraph();
|
||
} catch { /* silently ignore */ }
|
||
}
|
||
|
||
function initGraphLayout(graph) {
|
||
const w = canvas.width || 280;
|
||
const h = canvas.height || 300;
|
||
const cx = w / 2, cy = h / 2;
|
||
const count = graph.nodes.length;
|
||
|
||
graphState.nodes = graph.nodes.map((n, i) => {
|
||
const angle = (2 * Math.PI * i) / Math.max(count, 1);
|
||
const r = Math.min(cx, cy) * 0.65;
|
||
return { ...n, x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle), vx: 0, vy: 0 };
|
||
});
|
||
|
||
for (let iter = 0; iter < 60; iter++) tickForce();
|
||
}
|
||
|
||
function tickForce() {
|
||
const nodes = graphState.nodes;
|
||
const edges = graphState.data.edges || [];
|
||
const w = canvas.width || 280;
|
||
const h = canvas.height || 300;
|
||
const k = 80;
|
||
const repulsion = 3000;
|
||
|
||
nodes.forEach(n => { n.fx = 0; n.fy = 0; });
|
||
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
for (let j = i + 1; j < nodes.length; j++) {
|
||
const dx = nodes[j].x - nodes[i].x;
|
||
const dy = nodes[j].y - nodes[i].y;
|
||
const dist = Math.max(Math.sqrt(dx*dx + dy*dy), 0.1);
|
||
const f = repulsion / (dist * dist);
|
||
const fx = f * dx / dist;
|
||
const fy = f * dy / dist;
|
||
nodes[i].fx -= fx; nodes[i].fy -= fy;
|
||
nodes[j].fx += fx; nodes[j].fy += fy;
|
||
}
|
||
}
|
||
|
||
const nodeIndex = Object.fromEntries(nodes.map(n => [n.id, n]));
|
||
for (const edge of edges) {
|
||
const a = nodeIndex[edge.from];
|
||
const b = nodeIndex[edge.to];
|
||
if (!a || !b) continue;
|
||
const dx = b.x - a.x;
|
||
const dy = b.y - a.y;
|
||
const dist = Math.max(Math.sqrt(dx*dx + dy*dy), 0.1);
|
||
const f = (dist - k) * 0.05;
|
||
const fx = f * dx / dist;
|
||
const fy = f * dy / dist;
|
||
a.fx += fx; a.fy += fy;
|
||
b.fx -= fx; b.fy -= fy;
|
||
}
|
||
|
||
nodes.forEach(n => {
|
||
n.fx += (w/2 - n.x) * 0.01;
|
||
n.fy += (h/2 - n.y) * 0.01;
|
||
});
|
||
|
||
const damping = 0.7;
|
||
nodes.forEach(n => {
|
||
n.vx = (n.vx + n.fx) * damping;
|
||
n.vy = (n.vy + n.fy) * damping;
|
||
n.x += n.vx;
|
||
n.y += n.vy;
|
||
n.x = Math.max(24, Math.min(w - 24, n.x));
|
||
n.y = Math.max(18, Math.min(h - 18, n.y));
|
||
});
|
||
}
|
||
|
||
function drawGraph() {
|
||
const nodes = graphState.nodes;
|
||
const edges = graphState.data.edges || [];
|
||
const w = canvas.width;
|
||
const h = canvas.height;
|
||
const z = graphState.zoom || 1;
|
||
const px = graphState.panX || 0;
|
||
const py = graphState.panY || 0;
|
||
|
||
ctx.clearRect(0, 0, w, h);
|
||
const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() || '#080b0f';
|
||
ctx.fillStyle = bgColor;
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
if (nodes.length === 0) {
|
||
ctx.fillStyle = '#3a4a5a';
|
||
ctx.font = '11px DM Mono, monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('No types defined', w/2, h/2);
|
||
return;
|
||
}
|
||
|
||
// Apply zoom+pan transform
|
||
ctx.save();
|
||
ctx.translate(px, py);
|
||
ctx.scale(z, z);
|
||
|
||
const nodeIndex = Object.fromEntries(nodes.map(n => [n.id, n]));
|
||
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
||
ctx.lineWidth = 1 / z;
|
||
|
||
for (const edge of edges) {
|
||
const a = nodeIndex[edge.from];
|
||
const b = nodeIndex[edge.to];
|
||
if (!a || !b) continue;
|
||
ctx.beginPath();
|
||
ctx.moveTo(a.x, a.y);
|
||
ctx.lineTo(b.x, b.y);
|
||
ctx.stroke();
|
||
|
||
const angle = Math.atan2(b.y - a.y, b.x - a.x);
|
||
const arrowLen = 6 / z;
|
||
const tx = b.x - Math.cos(angle) * 12 / z;
|
||
const ty = b.y - Math.sin(angle) * 12 / z;
|
||
ctx.beginPath();
|
||
ctx.moveTo(tx, ty);
|
||
ctx.lineTo(tx - arrowLen * Math.cos(angle - 0.4), ty - arrowLen * Math.sin(angle - 0.4));
|
||
ctx.lineTo(tx - arrowLen * Math.cos(angle + 0.4), ty - arrowLen * Math.sin(angle + 0.4));
|
||
ctx.closePath();
|
||
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||
ctx.fill();
|
||
}
|
||
|
||
ctx.font = `bold ${10 / z}px DM Mono, monospace`;
|
||
ctx.textAlign = 'center';
|
||
|
||
// Draw activated nodes with pulsing rings first (behind nodes)
|
||
const activationPulse = 0.5 + 0.5 * Math.sin(graphState.activationFrame * 0.12);
|
||
for (const node of nodes) {
|
||
if (!graphState.activatedNodes.has(node.id)) continue;
|
||
const r = (node.kind === 'builtin' ? 6 : 9) / z;
|
||
// Draw 3 pulsing rings
|
||
for (let ring = 1; ring <= 3; ring++) {
|
||
const ringR = r + (ring * 8 * activationPulse) / z;
|
||
const alpha = (0.4 - ring * 0.1) * activationPulse;
|
||
ctx.beginPath();
|
||
ctx.arc(node.x, node.y, ringR, 0, Math.PI * 2);
|
||
ctx.strokeStyle = `rgba(192,132,252,${alpha})`;
|
||
ctx.lineWidth = 1.5 / z;
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
for (const node of nodes) {
|
||
const isActivated = graphState.activatedNodes.has(node.id);
|
||
const color = isActivated ? '#c084fc' : nodeColor(node.kind);
|
||
const r = (node.kind === 'builtin' ? 6 : 9) / z;
|
||
ctx.beginPath();
|
||
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
||
ctx.fillStyle = color;
|
||
ctx.fill();
|
||
|
||
if (node.kind !== 'builtin' && node.kind !== 'primitive') {
|
||
const shadowIntensity = isActivated ? 16 + 8 * activationPulse : 8;
|
||
ctx.shadowColor = color;
|
||
ctx.shadowBlur = shadowIntensity / z;
|
||
ctx.beginPath();
|
||
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
||
ctx.fillStyle = color + '66';
|
||
ctx.fill();
|
||
ctx.shadowBlur = 0;
|
||
}
|
||
|
||
ctx.fillStyle = (node.kind === 'builtin' && !isActivated) ? '#4a5a6a' : '#e8edf3';
|
||
ctx.fillText(node.name, node.x, node.y - r - 4 / z);
|
||
}
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
// ── Graph zoom ────────────────────────────────────────────────────────────
|
||
graphState.zoom = 1.0;
|
||
graphState.panX = 0;
|
||
graphState.panY = 0;
|
||
|
||
document.getElementById('graph-zoom-in').addEventListener('click', () => {
|
||
graphState.zoom = Math.min(3, graphState.zoom * 1.25);
|
||
updateZoomLabel();
|
||
drawGraph();
|
||
});
|
||
document.getElementById('graph-zoom-out').addEventListener('click', () => {
|
||
graphState.zoom = Math.max(0.3, graphState.zoom / 1.25);
|
||
updateZoomLabel();
|
||
drawGraph();
|
||
});
|
||
document.getElementById('graph-reset').addEventListener('click', () => {
|
||
graphState.zoom = 1;
|
||
graphState.panX = 0;
|
||
graphState.panY = 0;
|
||
updateZoomLabel();
|
||
drawGraph();
|
||
});
|
||
|
||
function updateZoomLabel() {
|
||
document.getElementById('graph-zoom-label').textContent = Math.round(graphState.zoom * 100) + '%';
|
||
}
|
||
|
||
// Animation loop for activated node pulsing
|
||
function startActivationAnimation() {
|
||
if (graphState.animating) return;
|
||
graphState.animating = true;
|
||
function animLoop() {
|
||
if (graphState.activatedNodes.size === 0) {
|
||
graphState.animating = false;
|
||
return;
|
||
}
|
||
graphState.activationFrame++;
|
||
drawGraph();
|
||
requestAnimationFrame(animLoop);
|
||
}
|
||
requestAnimationFrame(animLoop);
|
||
}
|
||
|
||
// Detect activate statements in source and highlight corresponding nodes
|
||
function updateActivationHighlights(source) {
|
||
const activateRe = /activate\s+(\w+)\s+where/g;
|
||
const found = new Set();
|
||
let m;
|
||
while ((m = activateRe.exec(source)) !== null) {
|
||
found.add(m[1]);
|
||
}
|
||
|
||
const changed = found.size !== graphState.activatedNodes.size ||
|
||
[...found].some(id => !graphState.activatedNodes.has(id));
|
||
|
||
if (changed) {
|
||
graphState.activatedNodes = found;
|
||
if (found.size > 0) {
|
||
startActivationAnimation();
|
||
} else {
|
||
drawGraph();
|
||
}
|
||
}
|
||
}
|
||
|
||
canvas.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||
graphState.zoom = Math.max(0.3, Math.min(3, graphState.zoom * delta));
|
||
updateZoomLabel();
|
||
drawGraph();
|
||
}, { passive: false });
|
||
|
||
// ── Graph node click ──────────────────────────────────────────────────────
|
||
function getNodeAtPoint(x, y) {
|
||
const z = graphState.zoom;
|
||
const px = graphState.panX, py = graphState.panY;
|
||
for (const node of graphState.nodes) {
|
||
const nx = node.x * z + px;
|
||
const ny = node.y * z + py;
|
||
const r = (node.kind === 'builtin' ? 6 : 9) * z + 4;
|
||
if (Math.hypot(x - nx, y - ny) <= r) return node;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
canvas.addEventListener('click', e => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
const node = getNodeAtPoint(x, y);
|
||
const detail = document.getElementById('graph-node-detail');
|
||
if (node) {
|
||
const isActivated = graphState.activatedNodes.has(node.id);
|
||
detail.innerHTML = `
|
||
<span class="gnd-kind">${node.kind}</span>
|
||
<span class="gnd-name">${escapeHtml(node.name)}</span>
|
||
${isActivated ? '<span style="color:var(--purple);margin-left:6px;font-size:9px;">⟁ activated</span>' : ''}
|
||
${node.fields && node.fields.length > 0 ? `<div style="margin-top:2px;color:var(--text3);font-size:10px;">${node.fields.slice(0, 3).map(f => escapeHtml(f)).join(', ')}</div>` : ''}
|
||
`;
|
||
detail.classList.add('visible');
|
||
|
||
// Navigate editor to type definition
|
||
const source = editor.state.doc.toString();
|
||
const typeDefRe = new RegExp(`(?:^|\\n)(type|enum|protocol)\\s+${escapeRegex(node.name)}\\s*[{(]`, 'm');
|
||
const match = typeDefRe.exec(source);
|
||
if (match) {
|
||
try {
|
||
const pos = match.index + (source[match.index] === '\n' ? 1 : 0);
|
||
const lineInfo = editor.state.doc.lineAt(pos);
|
||
editor.dispatch({ selection: { anchor: lineInfo.from }, scrollIntoView: true });
|
||
editor.focus();
|
||
} catch {}
|
||
}
|
||
} else {
|
||
detail.classList.remove('visible');
|
||
}
|
||
});
|
||
|
||
canvas.addEventListener('mousedown', e => {
|
||
// Don't start drag on click (handled above)
|
||
graphState.dragging = true;
|
||
graphState.dragStart = { x: e.clientX, y: e.clientY };
|
||
});
|
||
canvas.addEventListener('mousemove', e => {
|
||
if (!graphState.dragging) return;
|
||
const dx = e.clientX - graphState.dragStart.x;
|
||
const dy = e.clientY - graphState.dragStart.y;
|
||
graphState.dragStart = { x: e.clientX, y: e.clientY };
|
||
graphState.panX = (graphState.panX || 0) + dx;
|
||
graphState.panY = (graphState.panY || 0) + dy;
|
||
drawGraph();
|
||
});
|
||
canvas.addEventListener('mouseup', () => { graphState.dragging = false; });
|
||
canvas.addEventListener('mouseleave', () => { graphState.dragging = false; });
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Minimap
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const minimapCanvas = document.getElementById('minimap-canvas');
|
||
const minimapCtx = minimapCanvas.getContext('2d');
|
||
const minimapViewport = document.getElementById('minimap-viewport');
|
||
const MINIMAP_WIDTH = 60;
|
||
let minimapAnimFrame = null;
|
||
|
||
function updateMinimap() {
|
||
if (minimapAnimFrame) cancelAnimationFrame(minimapAnimFrame);
|
||
minimapAnimFrame = requestAnimationFrame(_drawMinimap);
|
||
}
|
||
|
||
function _drawMinimap() {
|
||
const wrapper = document.getElementById('editor-wrapper');
|
||
const rect = wrapper.getBoundingClientRect();
|
||
const h = rect.height;
|
||
minimapCanvas.width = MINIMAP_WIDTH;
|
||
minimapCanvas.height = h;
|
||
|
||
const source = editor.state.doc.toString();
|
||
const lines = source.split('\n');
|
||
const totalLines = Math.max(lines.length, 1);
|
||
const lineH = Math.max(1, Math.min(3, h / totalLines));
|
||
const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() || '#080b0f';
|
||
|
||
minimapCtx.fillStyle = bgColor;
|
||
minimapCtx.fillRect(0, 0, MINIMAP_WIDTH, h);
|
||
|
||
// Draw each line as a colored bar
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const y = i * lineH;
|
||
if (y > h) break;
|
||
const line = lines[i];
|
||
if (!line.trim()) continue;
|
||
|
||
// Color-code by type of content
|
||
let color = 'rgba(122,138,154,0.4)';
|
||
const t = line.trim();
|
||
if (t.startsWith('//')) color = 'rgba(74,90,106,0.5)';
|
||
else if (t.startsWith('fn ')) color = 'rgba(56,189,248,0.6)';
|
||
else if (t.startsWith('type ') || t.startsWith('enum ')) color = 'rgba(167,139,250,0.6)';
|
||
else if (t.startsWith('let ')) color = 'rgba(74,222,128,0.5)';
|
||
|
||
minimapCtx.fillStyle = color;
|
||
const len = Math.min(MINIMAP_WIDTH, line.length * 0.4);
|
||
minimapCtx.fillRect(2, y, len, Math.max(1, lineH - 0.5));
|
||
}
|
||
|
||
// Draw viewport indicator
|
||
const cmScrollEl = wrapper.querySelector('.cm-scroller');
|
||
if (cmScrollEl) {
|
||
const scrollTop = cmScrollEl.scrollTop;
|
||
const scrollH = cmScrollEl.scrollHeight;
|
||
const clientH = cmScrollEl.clientHeight;
|
||
const ratio = scrollH > 0 ? clientH / scrollH : 1;
|
||
const vpTop = scrollH > 0 ? (scrollTop / scrollH) * h : 0;
|
||
const vpH = ratio * h;
|
||
minimapViewport.style.top = vpTop + 'px';
|
||
minimapViewport.style.height = vpH + 'px';
|
||
}
|
||
}
|
||
|
||
// Hook minimap updates to editor changes
|
||
setInterval(updateMinimap, 500); // fallback polling
|
||
|
||
// Minimap click-to-jump
|
||
const minimapContainer = document.getElementById('minimap-container');
|
||
minimapContainer.addEventListener('click', e => {
|
||
const rect = minimapContainer.getBoundingClientRect();
|
||
const relY = e.clientY - rect.top;
|
||
const ratio = relY / rect.height;
|
||
const source = editor.state.doc.toString();
|
||
const totalLines = Math.max(source.split('\n').length, 1);
|
||
const targetLine = Math.max(1, Math.min(Math.round(ratio * totalLines), totalLines));
|
||
try {
|
||
const lineInfo = editor.state.doc.line(targetLine);
|
||
editor.dispatch({ selection: { anchor: lineInfo.from }, scrollIntoView: true });
|
||
editor.focus();
|
||
} catch {}
|
||
});
|
||
|
||
// Also allow dragging on the minimap to scroll
|
||
let minimapDragging = false;
|
||
minimapContainer.addEventListener('mousedown', () => { minimapDragging = true; });
|
||
document.addEventListener('mousemove', e => {
|
||
if (!minimapDragging) return;
|
||
const rect = minimapContainer.getBoundingClientRect();
|
||
const relY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
|
||
const ratio = relY / rect.height;
|
||
const cmScroller = document.querySelector('.cm-scroller');
|
||
if (cmScroller) {
|
||
cmScroller.scrollTop = ratio * (cmScroller.scrollHeight - cmScroller.clientHeight);
|
||
}
|
||
});
|
||
document.addEventListener('mouseup', () => { minimapDragging = false; });
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Sticky scroll — show current function/type header at top of editor
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const stickyScrollEl = document.getElementById('sticky-scroll');
|
||
const STICKY_DEFS = /^(fn |type |enum |protocol |impl |test |seed )/;
|
||
|
||
let _stickyLineNum = null;
|
||
|
||
function updateStickyScroll() {
|
||
if (!editor) return;
|
||
const cmScroller = document.querySelector('.cm-scroller');
|
||
if (!cmScroller) return;
|
||
|
||
// Use CM6's actual viewport to determine what line is at top
|
||
const rect = cmScroller.getBoundingClientRect();
|
||
const topPos = editor.posAtCoords({ x: rect.left + 1, y: rect.top + 2 });
|
||
if (topPos == null) {
|
||
stickyScrollEl.classList.remove('visible');
|
||
return;
|
||
}
|
||
|
||
const firstVisibleLine = editor.state.doc.lineAt(topPos).number;
|
||
const doc = editor.state.doc;
|
||
|
||
if (firstVisibleLine <= 1) {
|
||
stickyScrollEl.classList.remove('visible');
|
||
return;
|
||
}
|
||
|
||
// Find the last definition line before firstVisibleLine
|
||
let bestText = null;
|
||
let bestLineNum = null;
|
||
for (let i = firstVisibleLine - 1; i >= 1; i--) {
|
||
try {
|
||
const line = doc.line(i);
|
||
const text = line.text.trim();
|
||
if (STICKY_DEFS.test(text)) {
|
||
bestText = text;
|
||
bestLineNum = i;
|
||
break;
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
if (bestText) {
|
||
const m = bestText.match(/^(\w+)\s+(\S+)/);
|
||
if (m) {
|
||
_stickyLineNum = bestLineNum;
|
||
stickyScrollEl.innerHTML = `<span class="sticky-kind">${escapeHtml(m[1])}</span><span class="sticky-name">${escapeHtml(bestText.slice(m[1].length + 1))}</span><span class="sticky-goto" title="Jump to definition">:${bestLineNum}</span>`;
|
||
stickyScrollEl.classList.add('visible');
|
||
}
|
||
} else {
|
||
stickyScrollEl.classList.remove('visible');
|
||
_stickyLineNum = null;
|
||
}
|
||
}
|
||
|
||
// Click sticky scroll to jump to definition
|
||
stickyScrollEl.addEventListener('click', () => {
|
||
if (_stickyLineNum == null) return;
|
||
try {
|
||
const lineInfo = editor.state.doc.line(_stickyLineNum);
|
||
editor.dispatch({ selection: { anchor: lineInfo.from }, scrollIntoView: true });
|
||
editor.focus();
|
||
} catch {}
|
||
});
|
||
|
||
// Attach scroll listener to CM scroller when it appears
|
||
function attachStickyScrollListener() {
|
||
const cmScroller = document.querySelector('.cm-scroller');
|
||
if (cmScroller) {
|
||
cmScroller.addEventListener('scroll', updateStickyScroll, { passive: true });
|
||
}
|
||
}
|
||
setTimeout(attachStickyScrollListener, 500);
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Git Diff panel
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function loadDiff(filePath) {
|
||
const output = document.getElementById('diff-output');
|
||
const label = document.getElementById('diff-file-label');
|
||
const path = filePath || (tabs.find(t => t.id === activeTabId) || {}).path;
|
||
if (!path) {
|
||
output.innerHTML = '<span style="color:var(--text3)">No file selected.</span>';
|
||
return;
|
||
}
|
||
label.textContent = path;
|
||
output.innerHTML = '<span style="color:var(--text3)">Loading diff...</span>';
|
||
try {
|
||
const resp = await fetch(`/api/git/diff?path=${encodeURIComponent(path)}`);
|
||
if (!resp.ok) throw new Error(await resp.text());
|
||
const diff = await resp.json();
|
||
if (!diff || !diff.trim()) {
|
||
output.innerHTML = '<span style="color:var(--green)">No changes.</span>';
|
||
return;
|
||
}
|
||
output.innerHTML = diff.split('\n').map(line => {
|
||
let cls = 'diff-ctx';
|
||
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
|
||
else if (line.startsWith('+')) cls = 'diff-add';
|
||
else if (line.startsWith('-')) cls = 'diff-del';
|
||
else if (line.startsWith('@@')) cls = 'diff-hunk';
|
||
return `<span class="diff-line ${cls}">${escapeHtml(line)}</span>`;
|
||
}).join('\n');
|
||
} catch (err) {
|
||
output.innerHTML = `<span style="color:var(--red)">Error: ${escapeHtml(String(err))}</span>`;
|
||
}
|
||
}
|
||
|
||
document.getElementById('btn-diff-refresh').addEventListener('click', () => loadDiff(null));
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Definition / References lookup (Ctrl+click or F12/Shift+F12)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function goToDefinition(pos) {
|
||
const source = editor.state.doc.toString();
|
||
try {
|
||
const resp = await fetch(`/api/definition?source=${encodeURIComponent(source)}&pos=${pos}`);
|
||
if (!resp.ok) return;
|
||
const loc = await resp.json();
|
||
if (!loc) { appendBuildLine('info', 'No definition found.'); return; }
|
||
const lineInfo = editor.state.doc.line(loc.line);
|
||
editor.dispatch({ selection: { anchor: lineInfo.from + Math.max(0, loc.col - 1) }, scrollIntoView: true });
|
||
editor.focus();
|
||
appendBuildLine('info', `Definition at line ${loc.line}: ${loc.snippet}`);
|
||
} catch {}
|
||
}
|
||
|
||
async function findReferences(pos) {
|
||
const source = editor.state.doc.toString();
|
||
try {
|
||
const resp = await fetch(`/api/references?source=${encodeURIComponent(source)}&pos=${pos}`);
|
||
if (!resp.ok) return;
|
||
const refs = await resp.json();
|
||
if (!refs.length) { appendBuildLine('info', 'No references found.'); return; }
|
||
switchBottomTab('build');
|
||
appendBuildLine('info', `Found ${refs.length} reference(s):`);
|
||
for (const r of refs) {
|
||
appendBuildLine('output', ` Line ${r.line}:${r.col} ${r.snippet}`);
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Go to line (Ctrl+G)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
function goToLine() {
|
||
const input = prompt('Go to line:');
|
||
if (!input) return;
|
||
const lineNum = parseInt(input.trim(), 10);
|
||
if (isNaN(lineNum) || lineNum < 1) return;
|
||
try {
|
||
const doc = editor.state.doc;
|
||
const clampedLine = Math.min(Math.max(1, lineNum), doc.lines);
|
||
const lineInfo = doc.line(clampedLine);
|
||
editor.dispatch({
|
||
selection: { anchor: lineInfo.from },
|
||
scrollIntoView: true,
|
||
});
|
||
editor.focus();
|
||
showToast(`Line ${clampedLine}`);
|
||
} catch {}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Toggle line comment (Ctrl+/)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
function toggleLineComment() {
|
||
const sel = editor.state.selection.main;
|
||
const doc = editor.state.doc;
|
||
const startLine = doc.lineAt(sel.from);
|
||
const endLine = doc.lineAt(sel.to);
|
||
|
||
// Determine if all selected lines are commented
|
||
const lines = [];
|
||
for (let i = startLine.number; i <= endLine.number; i++) {
|
||
lines.push(doc.line(i));
|
||
}
|
||
|
||
const allCommented = lines.every(l => l.text.trimStart().startsWith('//'));
|
||
|
||
const changes = lines.map(l => {
|
||
if (allCommented) {
|
||
// Remove comment
|
||
const ci = l.text.indexOf('//');
|
||
return { from: l.from + ci, to: l.from + ci + 2, insert: '' };
|
||
} else {
|
||
return { from: l.from, insert: '//' };
|
||
}
|
||
});
|
||
|
||
editor.dispatch({ changes });
|
||
editor.focus();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Tab keyboard shortcuts — Cmd+1..9 to switch tabs, middle-click to close
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
document.addEventListener('keydown', e => {
|
||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
|
||
const num = parseInt(e.key, 10);
|
||
if (num >= 1 && num <= 9) {
|
||
const idx = num - 1;
|
||
if (idx < tabs.length) {
|
||
e.preventDefault();
|
||
switchTab(tabs[idx].id);
|
||
}
|
||
}
|
||
}
|
||
// F12 — go to definition
|
||
if (e.key === 'F12' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
const pos = editor.state.selection.main.head;
|
||
goToDefinition(pos);
|
||
}
|
||
// Shift+F12 — find references
|
||
if (e.key === 'F12' && e.shiftKey) {
|
||
e.preventDefault();
|
||
const pos = editor.state.selection.main.head;
|
||
findReferences(pos);
|
||
}
|
||
});
|
||
|
||
// Middle-click to close tab (mousedown to prevent scroll)
|
||
document.getElementById('editor-tabs').addEventListener('mousedown', e => {
|
||
if (e.button === 1) { // middle click
|
||
e.preventDefault();
|
||
const tab = e.target.closest('.editor-tab');
|
||
if (tab) {
|
||
const id = parseInt(tab.dataset.tabId, 10);
|
||
closeTab(id);
|
||
}
|
||
}
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Bottom tabs
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
function switchBottomTab(name) {
|
||
document.querySelectorAll('.bottom-tab').forEach(t => t.classList.toggle('active', t.dataset.panel === name));
|
||
document.querySelectorAll('.bottom-panel-content').forEach(p =>
|
||
p.classList.toggle('active', p.id === `panel-${name}`)
|
||
);
|
||
if (name === 'plugins') loadPlugins();
|
||
if (name === 'terminal') initTerminal();
|
||
if (name === 'outline') refreshOutline();
|
||
if (name === 'diff') loadDiff(null);
|
||
if (name === 'knowledge') document.getElementById('kg-search-input').focus();
|
||
}
|
||
|
||
document.querySelectorAll('.bottom-tab').forEach(tab =>
|
||
tab.addEventListener('click', () => switchBottomTab(tab.dataset.panel))
|
||
);
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Plugins panel (improved)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function loadPlugins() {
|
||
const list = document.getElementById('plugin-list');
|
||
try {
|
||
const resp = await fetch('/api/plugins');
|
||
const plugins = await resp.json();
|
||
|
||
const installed = plugins.filter(p => p.installed);
|
||
const available = plugins.filter(p => !p.installed);
|
||
|
||
function renderPlugin(p) {
|
||
const hooks = (p.hooks || []).map(h =>
|
||
`<span class="plugin-badge hook">${h.replace('_', '')}</span>`
|
||
).join('');
|
||
const fp = p.first_party ? `<span class="plugin-badge first-party">1st party</span>` : '';
|
||
const status = p.installed
|
||
? `<span class="plugin-badge installed">installed</span>`
|
||
: `<span class="plugin-badge available">available</span>`;
|
||
|
||
const toggleBtn = p.installed
|
||
? (p.enabled
|
||
? `<button class="btn" style="font-size:10px;padding:3px 8px" onclick="pluginToggle('${escapeHtml(p.name)}',false)">Disable</button>`
|
||
: `<button class="btn btn-primary" style="font-size:10px;padding:3px 8px" onclick="pluginToggle('${escapeHtml(p.name)}',true)">Enable</button>`)
|
||
: '';
|
||
|
||
const actionBtn = p.installed
|
||
? `<button class="btn" style="font-size:10px;padding:3px 8px" onclick="pluginRemove('${escapeHtml(p.name)}')">Remove</button>`
|
||
: `<button class="btn btn-primary" style="font-size:10px;padding:3px 8px" onclick="pluginInstall('${escapeHtml(p.name)}')">Install</button>`;
|
||
|
||
return `<div class="plugin-item">
|
||
<div class="plugin-meta">
|
||
<div class="plugin-name">${escapeHtml(p.name)} <span style="color:var(--text3);font-weight:400;font-size:10px;">v${escapeHtml(p.version)}</span></div>
|
||
<div class="plugin-desc">${escapeHtml(p.description)}</div>
|
||
<div class="plugin-badges">${status}${fp}${hooks}</div>
|
||
</div>
|
||
${toggleBtn}
|
||
${actionBtn}
|
||
</div>`;
|
||
}
|
||
|
||
let html = '';
|
||
if (installed.length) {
|
||
html += `<div class="plugin-section-title">Installed</div>`;
|
||
html += installed.map(renderPlugin).join('');
|
||
}
|
||
if (available.length) {
|
||
html += `<div class="plugin-section-title" style="margin-top:8px">Available</div>`;
|
||
html += available.map(renderPlugin).join('');
|
||
}
|
||
list.innerHTML = html || '<div style="color:var(--text3)">No plugins.</div>';
|
||
} catch (err) {
|
||
list.innerHTML = `<div style="color:var(--red)">Error: ${escapeHtml(String(err))}</div>`;
|
||
}
|
||
}
|
||
|
||
window.pluginInstall = async (name) => {
|
||
await fetch('/api/plugins/install', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name }),
|
||
});
|
||
// Auto theme-switch for theme plugins
|
||
if (name === 'el-theme-light') applyTheme('light');
|
||
await loadPlugins();
|
||
};
|
||
|
||
window.pluginRemove = async (name) => {
|
||
await fetch('/api/plugins/remove', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name }),
|
||
});
|
||
if (name === 'el-theme-light') applyTheme('dark');
|
||
await loadPlugins();
|
||
};
|
||
|
||
window.pluginToggle = async (name, enable) => {
|
||
await fetch(enable ? '/api/plugins/enable' : '/api/plugins/disable', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name }),
|
||
});
|
||
await loadPlugins();
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Reasoning panel (hypothesis mode — legacy)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
document.getElementById('btn-reason').addEventListener('click', async () => {
|
||
const input = document.getElementById('reason-input');
|
||
const hypothesis = input.value.trim();
|
||
if (!hypothesis) return;
|
||
|
||
const output = document.getElementById('reason-output');
|
||
output.innerHTML = '<span style="color:var(--text3)">Querying Engram reasoning engine...</span>';
|
||
|
||
try {
|
||
const tab = tabs.find(t => t.id === activeTabId);
|
||
const resp = await fetch('/api/reason', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
hypothesis,
|
||
context: (tab ? tab.content : editor.state.doc.toString()).slice(0, 2000),
|
||
mode: 'hypothesis',
|
||
}),
|
||
});
|
||
const result = await resp.json();
|
||
const verdict = result.verdict || 'unresolved';
|
||
const verdictClass = verdict === 'supported' ? 'supported'
|
||
: verdict === 'refuted' ? 'refuted' : 'unresolved';
|
||
const evidenceHtml = (result.evidence || []).map(e =>
|
||
`<li class="evidence-item">${escapeHtml(e.text)} <span style="color:var(--text3)">(weight: ${(e.weight || 0).toFixed(2)})</span></li>`
|
||
).join('');
|
||
|
||
output.innerHTML = `
|
||
<div class="verdict ${verdictClass}">${verdict.toUpperCase()}</div>
|
||
<div class="confidence">Confidence: ${((result.confidence || 0) * 100).toFixed(1)}% — source: ${escapeHtml(result.source || '')}</div>
|
||
${evidenceHtml ? `<ul class="evidence-list">${evidenceHtml}</ul>` : ''}
|
||
`;
|
||
} catch (err) {
|
||
output.innerHTML = `<span style="color:var(--red)">Error: ${escapeHtml(String(err))}</span>`;
|
||
}
|
||
});
|
||
|
||
document.getElementById('reason-input').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') document.getElementById('btn-reason').click();
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Neuron Pair Programming Panel
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
let neuronHistory = []; // [{role, content}]
|
||
let neuronPanelVisible = false;
|
||
let neuronThinking = false;
|
||
|
||
const neuronPanel = document.getElementById('neuron-panel');
|
||
const neuronMessages = document.getElementById('neuron-messages');
|
||
const neuronInput = document.getElementById('neuron-input');
|
||
const neuronSend = document.getElementById('neuron-send');
|
||
const neuronStatusDot = document.getElementById('neuron-status-dot');
|
||
const neuronPanelStatus = document.getElementById('neuron-panel-status');
|
||
|
||
function toggleNeuronPanel() {
|
||
neuronPanelVisible = !neuronPanelVisible;
|
||
neuronPanel.style.display = neuronPanelVisible ? 'flex' : 'none';
|
||
const btn = document.getElementById('btn-neuron-toggle');
|
||
btn.classList.toggle('neuron-active', neuronPanelVisible);
|
||
}
|
||
|
||
document.getElementById('btn-neuron-toggle').addEventListener('click', toggleNeuronPanel);
|
||
|
||
// Quick action buttons
|
||
document.getElementById('nq-explain').addEventListener('click', () => {
|
||
const sel = editor.state.selection.main;
|
||
const selection = sel.empty ? '' : editor.state.doc.sliceString(sel.from, sel.to);
|
||
if (selection) {
|
||
neuronInput.value = 'Explain this selected code:';
|
||
sendNeuronMessage('Explain this selected code', selection);
|
||
} else {
|
||
neuronInput.value = 'Explain what this file does';
|
||
sendNeuronMessage('Explain what this file does');
|
||
}
|
||
});
|
||
|
||
document.getElementById('nq-activate').addEventListener('click', () => {
|
||
sendNeuronMessage('What activate queries would be useful for the types in this file? Show me examples.');
|
||
});
|
||
|
||
document.getElementById('nq-types').addEventListener('click', () => {
|
||
sendNeuronMessage('Analyze the type structure in this file. What semantic relationships do you see?');
|
||
});
|
||
|
||
document.getElementById('nq-clear').addEventListener('click', () => {
|
||
neuronHistory = [];
|
||
neuronMessages.innerHTML = `
|
||
<div class="neuron-msg">
|
||
<div class="neuron-msg-label neuron-label">Neuron</div>
|
||
<div class="neuron-msg-neuron">Conversation cleared. Ready to help with your Engram code.</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
// Send button and Enter key
|
||
neuronSend.addEventListener('click', () => {
|
||
const msg = neuronInput.value.trim();
|
||
if (msg) {
|
||
neuronInput.value = '';
|
||
sendNeuronMessage(msg);
|
||
}
|
||
});
|
||
|
||
neuronInput.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
const msg = neuronInput.value.trim();
|
||
if (msg) {
|
||
neuronInput.value = '';
|
||
sendNeuronMessage(msg);
|
||
}
|
||
}
|
||
});
|
||
|
||
function appendNeuronMessage(role, content, codeBlocks = []) {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'neuron-msg';
|
||
|
||
const label = document.createElement('div');
|
||
label.className = `neuron-msg-label ${role === 'user' ? 'user-label' : 'neuron-label'}`;
|
||
label.textContent = role === 'user' ? 'You' : 'Neuron';
|
||
wrapper.appendChild(label);
|
||
|
||
if (role === 'user') {
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'neuron-msg-user';
|
||
bubble.textContent = content;
|
||
wrapper.appendChild(bubble);
|
||
} else {
|
||
// Parse and render Neuron response with code blocks
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'neuron-msg-neuron';
|
||
|
||
// Render inline code blocks from the message text
|
||
let text = content;
|
||
// Split on ```...``` blocks
|
||
const parts = text.split(/(```[\s\S]*?```)/g);
|
||
for (const part of parts) {
|
||
if (part.startsWith('```') && part.endsWith('```')) {
|
||
const inner = part.slice(3, -3);
|
||
const nlIdx = inner.indexOf('\n');
|
||
const lang = nlIdx >= 0 ? inner.slice(0, nlIdx).trim() : '';
|
||
const code = nlIdx >= 0 ? inner.slice(nlIdx + 1) : inner;
|
||
|
||
const codeEl = document.createElement('div');
|
||
codeEl.className = 'neuron-code-block';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'neuron-code-header';
|
||
header.innerHTML = `<span class="neuron-code-lang">${escapeHtml(lang || 'code')}</span>`;
|
||
|
||
const insertBtn = document.createElement('button');
|
||
insertBtn.className = 'neuron-code-insert';
|
||
insertBtn.textContent = '→ Insert at cursor';
|
||
insertBtn.addEventListener('click', () => {
|
||
const pos = editor.state.selection.main.head;
|
||
editor.dispatch({ changes: { from: pos, insert: code.trim() + '\n' } });
|
||
editor.focus();
|
||
showToast('Code inserted at cursor');
|
||
});
|
||
header.appendChild(insertBtn);
|
||
codeEl.appendChild(header);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'neuron-code-body';
|
||
body.textContent = code.trim();
|
||
codeEl.appendChild(body);
|
||
|
||
bubble.appendChild(codeEl);
|
||
} else if (part.trim()) {
|
||
const textNode = document.createElement('div');
|
||
textNode.style.cssText = 'white-space: pre-wrap; word-break: break-word;';
|
||
textNode.textContent = part;
|
||
bubble.appendChild(textNode);
|
||
}
|
||
}
|
||
wrapper.appendChild(bubble);
|
||
}
|
||
|
||
neuronMessages.appendChild(wrapper);
|
||
neuronMessages.scrollTop = neuronMessages.scrollHeight;
|
||
}
|
||
|
||
function showNeuronThinking() {
|
||
const el = document.createElement('div');
|
||
el.className = 'neuron-thinking';
|
||
el.id = 'neuron-thinking-indicator';
|
||
el.innerHTML = `
|
||
<span style="color:var(--purple)">Neuron</span>
|
||
<div class="thinking-dots">
|
||
<span>·</span><span>·</span><span>·</span>
|
||
</div>
|
||
`;
|
||
neuronMessages.appendChild(el);
|
||
neuronMessages.scrollTop = neuronMessages.scrollHeight;
|
||
}
|
||
|
||
function hideNeuronThinking() {
|
||
const el = document.getElementById('neuron-thinking-indicator');
|
||
if (el) el.remove();
|
||
}
|
||
|
||
async function sendNeuronMessage(message, selection = null) {
|
||
if (neuronThinking) return;
|
||
|
||
// Auto-open panel
|
||
if (!neuronPanelVisible) toggleNeuronPanel();
|
||
|
||
// Get selection from editor if not provided
|
||
if (!selection) {
|
||
const sel = editor.state.selection.main;
|
||
if (!sel.empty) {
|
||
selection = editor.state.doc.sliceString(sel.from, sel.to);
|
||
}
|
||
}
|
||
|
||
// Get current file context
|
||
const tab = tabs.find(t => t.id === activeTabId);
|
||
const context = tab ? tab.content : editor.state.doc.toString();
|
||
|
||
// Add to history and display
|
||
neuronHistory.push({ role: 'user', content: message });
|
||
appendNeuronMessage('user', message);
|
||
|
||
neuronThinking = true;
|
||
neuronStatusDot.className = 'neuron-status-dot active';
|
||
neuronPanelStatus.textContent = 'thinking...';
|
||
neuronSend.disabled = true;
|
||
showNeuronThinking();
|
||
|
||
try {
|
||
const resp = await fetch('/api/reason', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
mode: 'pair',
|
||
message,
|
||
context: context.slice(0, 4000),
|
||
selection: selection || '',
|
||
conversation_history: neuronHistory.slice(-10), // last 10 messages for context
|
||
}),
|
||
});
|
||
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const result = await resp.json();
|
||
const responseText = result.message || 'No response.';
|
||
|
||
// Add to history
|
||
neuronHistory.push({ role: 'assistant', content: responseText });
|
||
|
||
hideNeuronThinking();
|
||
appendNeuronMessage('neuron', responseText);
|
||
|
||
// Update status
|
||
neuronStatusDot.className = `neuron-status-dot ${result.source === 'stub' ? '' : 'connected'}`;
|
||
neuronPanelStatus.textContent = result.source === 'stub' ? 'stub mode' : 'connected';
|
||
|
||
} catch (err) {
|
||
hideNeuronThinking();
|
||
appendNeuronMessage('neuron', `Error: ${err.message}\n\nMake sure the server is running and EL_ENGRAM_URL is configured.`);
|
||
neuronStatusDot.className = 'neuron-status-dot';
|
||
neuronPanelStatus.textContent = 'error';
|
||
}
|
||
|
||
neuronThinking = false;
|
||
neuronSend.disabled = false;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Live Activation Preview
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const activationPreview = document.getElementById('activation-preview');
|
||
let activationPreviewTimer = null;
|
||
let lastActivationQuery = '';
|
||
|
||
// Detect `activate TypeName where "query"` pattern near cursor
|
||
function detectActivateContext(view) {
|
||
const pos = view.state.selection.main.head;
|
||
const doc = view.state.doc;
|
||
const line = doc.lineAt(pos);
|
||
const lineText = line.text;
|
||
|
||
// Look for activate pattern on current line
|
||
const activateMatch = lineText.match(/activate\s+(\w+)\s+where\s+"([^"]*)"/);
|
||
if (!activateMatch) {
|
||
// Check if we're inside a string after `where`
|
||
const partialMatch = lineText.match(/activate\s+(\w+)\s+where\s+"([^"]*)/);
|
||
if (!partialMatch) return null;
|
||
// Check cursor is inside the string
|
||
const strStart = lineText.indexOf('"', lineText.indexOf('where'));
|
||
const strEnd = lineText.length; // partial — no closing quote
|
||
const linePos = pos - line.from;
|
||
if (linePos >= strStart && linePos <= strEnd) {
|
||
return { typeName: partialMatch[1], query: partialMatch[2], partial: true };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
return { typeName: activateMatch[1], query: activateMatch[2], partial: false };
|
||
}
|
||
|
||
async function updateActivationPreview(view) {
|
||
const ctx = detectActivateContext(view);
|
||
if (!ctx || ctx.query.length < 2) {
|
||
activationPreview.classList.remove('visible');
|
||
return;
|
||
}
|
||
|
||
const key = `${ctx.typeName}:${ctx.query}`;
|
||
if (key === lastActivationQuery) return;
|
||
lastActivationQuery = key;
|
||
|
||
try {
|
||
const params = new URLSearchParams({
|
||
type_name: ctx.typeName,
|
||
query: ctx.query,
|
||
});
|
||
const resp = await fetch(`/api/lsp/activate-preview?${params}`);
|
||
if (!resp.ok) return;
|
||
|
||
const data = await resp.json();
|
||
|
||
// Update preview UI
|
||
document.getElementById('ap-count').textContent = data.count || (data.connected ? '0' : '?');
|
||
document.getElementById('ap-type-name').textContent = ctx.typeName;
|
||
document.getElementById('ap-query-text').textContent = `"${ctx.query}"`;
|
||
|
||
const nodesList = document.getElementById('ap-nodes-list');
|
||
|
||
if (!data.connected) {
|
||
nodesList.innerHTML = '<div class="ap-disconnected">Connect Engram DB for live previews</div>';
|
||
} else if (data.nodes && data.nodes.length > 0) {
|
||
nodesList.innerHTML = data.nodes.map(n => `
|
||
<div class="ap-node">
|
||
<span class="ap-node-label">${escapeHtml(n.label || n.id)}</span>
|
||
<span class="ap-node-score">${Math.round((n.score || 0) * 100)}%</span>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
nodesList.innerHTML = '<div class="ap-disconnected">No matching nodes</div>';
|
||
}
|
||
|
||
// Position below current line
|
||
const pos = view.state.selection.main.head;
|
||
const coords = view.coordsAtPos(pos);
|
||
if (coords) {
|
||
const editorWrapper = document.getElementById('editor-wrapper');
|
||
const wrapperRect = editorWrapper.getBoundingClientRect();
|
||
activationPreview.style.left = Math.max(4, coords.left - wrapperRect.left) + 'px';
|
||
activationPreview.style.top = (coords.bottom - wrapperRect.top + 4) + 'px';
|
||
activationPreview.classList.add('visible');
|
||
}
|
||
} catch {
|
||
activationPreview.classList.remove('visible');
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Knowledge Graph Explorer Panel
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
document.getElementById('btn-kg-search').addEventListener('click', runKnowledgeSearch);
|
||
document.getElementById('kg-search-input').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') runKnowledgeSearch();
|
||
});
|
||
|
||
async function runKnowledgeSearch() {
|
||
const query = document.getElementById('kg-search-input').value.trim();
|
||
if (!query) return;
|
||
|
||
const results = document.getElementById('kg-results');
|
||
results.innerHTML = '<div class="kg-status-msg">Searching knowledge graph...</div>';
|
||
|
||
try {
|
||
const resp = await fetch('/api/reason', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
mode: 'knowledge-search',
|
||
query,
|
||
}),
|
||
});
|
||
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const data = await resp.json();
|
||
|
||
if (data.source === 'stub' || !data.nodes || data.nodes.length === 0) {
|
||
const msg = data.evidence && data.evidence.length > 0
|
||
? data.evidence[0].text
|
||
: 'No results found.';
|
||
results.innerHTML = `<div class="kg-status-msg">${escapeHtml(msg)}</div>`;
|
||
return;
|
||
}
|
||
|
||
results.innerHTML = data.nodes.map(node => `
|
||
<div class="kg-node-card" data-node-id="${escapeHtml(node.id)}" data-node-label="${escapeHtml(node.label)}" data-node-type="${escapeHtml(node.node_type)}">
|
||
<div class="kg-node-header">
|
||
<span class="kg-node-label">${escapeHtml(node.label || node.id)}</span>
|
||
<span class="kg-node-type">${escapeHtml(node.node_type || 'node')}</span>
|
||
<span class="kg-node-score">${Math.round((node.score || 0) * 100)}%</span>
|
||
</div>
|
||
<div class="kg-node-id">id: ${escapeHtml(node.id)}</div>
|
||
<div class="kg-node-actions">
|
||
<button class="kg-use-btn" onclick="insertActivateFromKg('${escapeHtml(node.node_type || 'Entity')}', '${escapeHtml(node.label || node.id)}')">
|
||
Use in activate
|
||
</button>
|
||
<button class="kg-use-btn" style="color:var(--accent);border-color:rgba(56,189,248,0.3);background:rgba(56,189,248,0.08);" onclick="askNeuronAboutNode('${escapeHtml(node.label || node.id)}', '${escapeHtml(node.node_type || '')}')">
|
||
Ask Neuron
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
} catch (err) {
|
||
results.innerHTML = `<div style="color:var(--red);font-size:11px;">Error: ${escapeHtml(String(err))}</div>`;
|
||
}
|
||
}
|
||
|
||
window.insertActivateFromKg = (nodeType, nodeLabel) => {
|
||
const pos = editor.state.selection.main.head;
|
||
const snippet = `activate ${nodeType} where "${nodeLabel}"`;
|
||
editor.dispatch({ changes: { from: pos, insert: snippet } });
|
||
editor.focus();
|
||
showToast(`Inserted activate expression`);
|
||
};
|
||
|
||
window.askNeuronAboutNode = (label, nodeType) => {
|
||
sendNeuronMessage(`Tell me about the "${label}" ${nodeType} node in the knowledge graph and how I could use it in an activate query.`);
|
||
if (!neuronPanelVisible) toggleNeuronPanel();
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Theming
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
function applyTheme(name) {
|
||
const html = document.documentElement;
|
||
html.classList.remove('theme-dark', 'theme-light', 'theme-neuron', 'theme-high-contrast');
|
||
if (name !== 'dark') html.classList.add(`theme-${name}`);
|
||
localStorage.setItem('el-ide-theme', name);
|
||
|
||
// Update header theme menu active state
|
||
document.querySelectorAll('.theme-option').forEach(el =>
|
||
el.classList.toggle('active', el.dataset.theme === name)
|
||
);
|
||
// Update settings theme grid
|
||
document.querySelectorAll('.settings-theme-btn').forEach(el =>
|
||
el.classList.toggle('active', el.dataset.theme === name)
|
||
);
|
||
|
||
// Notify backend
|
||
fetch('/api/themes/active', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name }),
|
||
}).catch(() => {});
|
||
|
||
// Persist theme in settings
|
||
saveSettingsToApi({ theme: name });
|
||
|
||
// Redraw graph since canvas colors change
|
||
drawGraph();
|
||
}
|
||
|
||
// Header theme button
|
||
const btnTheme = document.getElementById('btn-theme');
|
||
const themeMenu = document.getElementById('theme-menu');
|
||
|
||
btnTheme.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
themeMenu.style.display = themeMenu.style.display === 'none' ? 'block' : 'none';
|
||
});
|
||
|
||
document.addEventListener('click', e => {
|
||
if (!btnTheme.contains(e.target) && !themeMenu.contains(e.target)) {
|
||
themeMenu.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
document.querySelectorAll('.theme-option').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
applyTheme(el.dataset.theme);
|
||
themeMenu.style.display = 'none';
|
||
});
|
||
});
|
||
|
||
// Settings theme buttons
|
||
document.querySelectorAll('.settings-theme-btn').forEach(el => {
|
||
el.addEventListener('click', () => applyTheme(el.dataset.theme));
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Settings
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const fontSizeSlider = document.getElementById('setting-font-size');
|
||
const fontSizeVal = document.getElementById('setting-font-size-val');
|
||
const tabSizeSelect = document.getElementById('setting-tab-size');
|
||
const wordWrapCheck = document.getElementById('setting-word-wrap');
|
||
|
||
// Word wrap compartment for dynamic reconfiguration
|
||
let wordWrapEnabled = false;
|
||
|
||
function applySetting(key, value) {
|
||
localStorage.setItem(`el-ide-setting-${key}`, value);
|
||
if (key === 'fontSize') {
|
||
document.getElementById('cm-editor').style.fontSize = value + 'px';
|
||
fontSizeVal.textContent = value + 'px';
|
||
}
|
||
if (key === 'wordWrap') {
|
||
wordWrapEnabled = value === '1' || value === true;
|
||
applyWordWrap(wordWrapEnabled);
|
||
}
|
||
}
|
||
|
||
function applyWordWrap(enable) {
|
||
// Toggle word wrap via CSS on the CM content
|
||
const cmContent = document.querySelector('.cm-content');
|
||
const cmScroller = document.querySelector('.cm-scroller');
|
||
if (cmContent) {
|
||
if (enable) {
|
||
cmContent.style.whiteSpace = 'pre-wrap';
|
||
cmContent.style.wordBreak = 'break-all';
|
||
} else {
|
||
cmContent.style.whiteSpace = 'pre';
|
||
cmContent.style.wordBreak = '';
|
||
}
|
||
}
|
||
if (cmScroller) {
|
||
cmScroller.style.overflowX = enable ? 'hidden' : 'auto';
|
||
}
|
||
}
|
||
|
||
fontSizeSlider.addEventListener('input', () => {
|
||
applySetting('fontSize', fontSizeSlider.value);
|
||
saveSettingsToApi({ fontSize: parseInt(fontSizeSlider.value, 10) });
|
||
});
|
||
|
||
tabSizeSelect.addEventListener('change', () => {
|
||
localStorage.setItem('el-ide-setting-tabSize', tabSizeSelect.value);
|
||
saveSettingsToApi({ tabSize: parseInt(tabSizeSelect.value, 10) });
|
||
});
|
||
|
||
wordWrapCheck.addEventListener('change', () => {
|
||
applySetting('wordWrap', wordWrapCheck.checked ? '1' : '0');
|
||
saveSettingsToApi({ wordWrap: wordWrapCheck.checked });
|
||
});
|
||
|
||
document.getElementById('setting-vim-mode').addEventListener('change', e => {
|
||
setVimMode(e.target.checked);
|
||
saveSettingsToApi({ vimMode: e.target.checked });
|
||
});
|
||
|
||
document.getElementById('setting-format-on-save').addEventListener('change', e => {
|
||
formatOnSave = e.target.checked;
|
||
localStorage.setItem('el-ide-setting-formatOnSave', formatOnSave ? '1' : '0');
|
||
saveSettingsToApi({ formatOnSave: e.target.checked });
|
||
});
|
||
|
||
function applySettingsObject(s) {
|
||
if (s.fontSize) {
|
||
fontSizeSlider.value = s.fontSize;
|
||
applySetting('fontSize', s.fontSize);
|
||
localStorage.setItem('el-ide-setting-fontSize', s.fontSize);
|
||
}
|
||
if (s.tabSize) {
|
||
tabSizeSelect.value = s.tabSize;
|
||
localStorage.setItem('el-ide-setting-tabSize', s.tabSize);
|
||
}
|
||
if (s.wordWrap !== undefined) {
|
||
wordWrapCheck.checked = !!s.wordWrap;
|
||
applySetting('wordWrap', s.wordWrap ? '1' : '0');
|
||
localStorage.setItem('el-ide-setting-wordWrap', s.wordWrap ? '1' : '0');
|
||
}
|
||
if (s.vimMode !== undefined && s.vimMode) {
|
||
document.getElementById('setting-vim-mode').checked = true;
|
||
setVimMode(true);
|
||
}
|
||
if (s.formatOnSave !== undefined && s.formatOnSave) {
|
||
document.getElementById('setting-format-on-save').checked = true;
|
||
formatOnSave = true;
|
||
}
|
||
if (s.theme) {
|
||
applyTheme(s.theme);
|
||
}
|
||
}
|
||
|
||
async function loadSettingsFromApi() {
|
||
try {
|
||
const resp = await fetch('/api/settings');
|
||
if (!resp.ok) return;
|
||
const s = await resp.json();
|
||
applySettingsObject(s);
|
||
} catch { /* fall through to localStorage */ }
|
||
}
|
||
|
||
// Debounced save to API
|
||
let _settingsSaveTimer = null;
|
||
function saveSettingsToApi(patch) {
|
||
clearTimeout(_settingsSaveTimer);
|
||
_settingsSaveTimer = setTimeout(async () => {
|
||
try {
|
||
await fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ settings: patch }),
|
||
});
|
||
} catch { /* silently ignore */ }
|
||
}, 500);
|
||
}
|
||
|
||
function restoreSettings() {
|
||
// Try localStorage as immediate fallback (before API call resolves)
|
||
const fs = localStorage.getItem('el-ide-setting-fontSize');
|
||
if (fs) {
|
||
fontSizeSlider.value = fs;
|
||
applySetting('fontSize', fs);
|
||
}
|
||
const ts = localStorage.getItem('el-ide-setting-tabSize');
|
||
if (ts) tabSizeSelect.value = ts;
|
||
const ww = localStorage.getItem('el-ide-setting-wordWrap');
|
||
if (ww) wordWrapCheck.checked = ww === '1';
|
||
const vm = localStorage.getItem('el-ide-setting-vimMode');
|
||
if (vm === '1') {
|
||
document.getElementById('setting-vim-mode').checked = true;
|
||
setVimMode(true);
|
||
}
|
||
const fos = localStorage.getItem('el-ide-setting-formatOnSave');
|
||
if (fos === '1') {
|
||
document.getElementById('setting-format-on-save').checked = true;
|
||
formatOnSave = true;
|
||
}
|
||
// Restore bottom panel height
|
||
const bh = localStorage.getItem('el-ide-bottom-h');
|
||
if (bh) {
|
||
const panel = document.getElementById('bottom-panel');
|
||
panel.style.height = bh + 'px';
|
||
panel.style.minHeight = bh + 'px';
|
||
}
|
||
// Restore file tree width
|
||
const tw = localStorage.getItem('el-ide-tree-w');
|
||
if (tw) {
|
||
const tree = document.getElementById('file-tree');
|
||
tree.style.width = tw + 'px';
|
||
tree.style.minWidth = '100px';
|
||
}
|
||
// Restore type-graph width
|
||
const gw = localStorage.getItem('el-ide-graph-w');
|
||
if (gw) {
|
||
const tg = document.getElementById('type-graph');
|
||
if (tg) {
|
||
tg.style.width = gw + 'px';
|
||
tg.style.minWidth = '120px';
|
||
tg.style.flex = 'none';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Terminal (xterm.js via CDN, lazy loaded)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function initTerminal() {
|
||
const container = document.getElementById('xterm-container');
|
||
if (xterm) {
|
||
// Already initialized — just fit
|
||
xterm.focus();
|
||
return;
|
||
}
|
||
|
||
// Lazy-load xterm.js CSS
|
||
if (!document.getElementById('xterm-css')) {
|
||
const link = document.createElement('link');
|
||
link.id = 'xterm-css';
|
||
link.rel = 'stylesheet';
|
||
link.href = 'https://esm.sh/xterm@5/css/xterm.css';
|
||
document.head.appendChild(link);
|
||
}
|
||
|
||
try {
|
||
const { Terminal } = await import('https://esm.sh/xterm@5');
|
||
const { FitAddon } = await import('https://esm.sh/xterm-addon-fit@0.8');
|
||
|
||
xterm = new Terminal({
|
||
theme: {
|
||
background: '#0a0a0a',
|
||
foreground: '#e8edf3',
|
||
cursor: '#38bdf8',
|
||
selection: 'rgba(56,189,248,0.2)',
|
||
},
|
||
fontFamily: 'DM Mono, monospace',
|
||
fontSize: 12,
|
||
cursorBlink: true,
|
||
scrollback: 1000,
|
||
});
|
||
|
||
const fitAddon = new FitAddon();
|
||
xterm.loadAddon(fitAddon);
|
||
xterm.open(container);
|
||
fitAddon.fit();
|
||
|
||
// Connect WebSocket
|
||
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
xtermWs = new WebSocket(`${wsProto}://${location.host}/api/terminal`);
|
||
|
||
xtermWs.onopen = () => {
|
||
xterm.writeln('\x1b[32mConnected to shell\x1b[0m');
|
||
};
|
||
xtermWs.onmessage = (e) => {
|
||
xterm.write(e.data);
|
||
};
|
||
xtermWs.onclose = () => {
|
||
xterm.writeln('\r\n\x1b[31mConnection closed\x1b[0m');
|
||
};
|
||
xtermWs.onerror = () => {
|
||
xterm.writeln('\r\n\x1b[31mWebSocket error\x1b[0m');
|
||
};
|
||
|
||
xterm.onData(data => {
|
||
if (xtermWs && xtermWs.readyState === WebSocket.OPEN) {
|
||
xtermWs.send(data);
|
||
}
|
||
});
|
||
|
||
// Re-fit on resize
|
||
const resizeObs = new ResizeObserver(() => {
|
||
try { fitAddon.fit(); } catch {}
|
||
});
|
||
resizeObs.observe(container);
|
||
|
||
xtermLoaded = true;
|
||
xterm.focus();
|
||
} catch (err) {
|
||
container.innerHTML = `<div style="color:var(--red);padding:12px;font-family:monospace;font-size:12px;">Terminal error: ${escapeHtml(String(err))}</div>`;
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Vim keybindings (lazy loaded from esm.sh)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function setVimMode(enable) {
|
||
vimEnabled = enable;
|
||
localStorage.setItem('el-ide-setting-vimMode', enable ? '1' : '0');
|
||
const indicator = document.getElementById('status-vim-mode');
|
||
|
||
if (enable) {
|
||
try {
|
||
const { vim } = await import('https://esm.sh/@replit/codemirror-vim@6');
|
||
// Reconfigure editor with vim extension
|
||
editor.dispatch({
|
||
effects: StateEffect.appendConfig.of(vim()),
|
||
});
|
||
indicator.textContent = 'VIM';
|
||
indicator.style.display = '';
|
||
const sep = document.createElement('span');
|
||
sep.className = 'status-sep';
|
||
sep.textContent = '|';
|
||
indicator.after(sep);
|
||
} catch (err) {
|
||
console.warn('Vim mode failed to load:', err);
|
||
}
|
||
} else {
|
||
// Can't easily remove extensions from CM6 without recreating the editor
|
||
// Show indicator change; full vim disable requires page reload
|
||
indicator.textContent = 'VIM OFF (reload to disable)';
|
||
indicator.style.display = '';
|
||
setTimeout(() => {
|
||
indicator.style.display = 'none';
|
||
}, 3000);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Document Outline
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function refreshOutline() {
|
||
const source = editor.state.doc.toString();
|
||
const list = document.getElementById('outline-list');
|
||
if (!source.trim()) {
|
||
list.innerHTML = '<div style="color:var(--text3);font-size:11px;">No content.</div>';
|
||
return;
|
||
}
|
||
try {
|
||
const resp = await fetch(`/api/outline?source=${encodeURIComponent(source)}`);
|
||
if (!resp.ok) return;
|
||
const items = await resp.json();
|
||
if (!items.length) {
|
||
list.innerHTML = '<div style="color:var(--text3);font-size:11px;padding:8px 0;">No symbols found.</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = items.map(item => `
|
||
<div class="outline-item" data-line="${item.line}">
|
||
<span class="outline-kind outline-kind-${item.kind}">${item.kind}</span>
|
||
<span class="outline-name">${escapeHtml(item.name)}</span>
|
||
<span class="outline-line">:${item.line}</span>
|
||
</div>
|
||
`).join('');
|
||
list.querySelectorAll('.outline-item').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
const lineNum = parseInt(el.dataset.line, 10);
|
||
try {
|
||
const lineInfo = editor.state.doc.line(lineNum);
|
||
editor.dispatch({ selection: { anchor: lineInfo.from }, scrollIntoView: true });
|
||
editor.focus();
|
||
} catch {}
|
||
});
|
||
});
|
||
} catch (err) {
|
||
list.innerHTML = `<div style="color:var(--red);font-size:11px;">Error: ${escapeHtml(String(err))}</div>`;
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Format on save
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
let formatOnSave = false;
|
||
|
||
async function formatCurrentFile() {
|
||
const tab = tabs.find(t => t.id === activeTabId);
|
||
if (!tab) return false;
|
||
const content = editor.state.doc.toString();
|
||
try {
|
||
const resp = await fetch('/api/format', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ content, path: tab.path }),
|
||
});
|
||
if (!resp.ok) return false;
|
||
const { content: formatted, changed } = await resp.json();
|
||
if (changed) {
|
||
const cur = editor.state.selection.main.anchor;
|
||
editor.dispatch({
|
||
changes: { from: 0, to: editor.state.doc.length, insert: formatted },
|
||
selection: { anchor: Math.min(cur, formatted.length) },
|
||
});
|
||
tab.content = formatted;
|
||
}
|
||
return true;
|
||
} catch { return false; }
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Resizable bottom panel
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
(function initResizeHandle() {
|
||
const handle = document.getElementById('bottom-resize-handle');
|
||
const panel = document.getElementById('bottom-panel');
|
||
let dragging = false;
|
||
let startY = 0;
|
||
let startH = 0;
|
||
|
||
handle.addEventListener('mousedown', e => {
|
||
dragging = true;
|
||
startY = e.clientY;
|
||
startH = panel.offsetHeight;
|
||
document.body.style.cursor = 'ns-resize';
|
||
document.body.style.userSelect = 'none';
|
||
});
|
||
|
||
document.addEventListener('mousemove', e => {
|
||
if (!dragging) return;
|
||
const delta = startY - e.clientY;
|
||
const newH = Math.max(80, Math.min(window.innerHeight * 0.7, startH + delta));
|
||
panel.style.height = newH + 'px';
|
||
panel.style.minHeight = newH + 'px';
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
if (!dragging) return;
|
||
dragging = false;
|
||
document.body.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
// Save to localStorage
|
||
localStorage.setItem('el-ide-bottom-h', panel.offsetHeight);
|
||
});
|
||
})();
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// File tree resize + collapse
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
let fileTreeCollapsed = false;
|
||
|
||
function toggleFileTree() {
|
||
const tree = document.getElementById('file-tree');
|
||
const handle = document.getElementById('file-tree-resize');
|
||
const btn = document.getElementById('btn-collapse-tree');
|
||
fileTreeCollapsed = !fileTreeCollapsed;
|
||
if (fileTreeCollapsed) {
|
||
tree.style.width = '0';
|
||
tree.style.minWidth = '0';
|
||
tree.style.overflow = 'hidden';
|
||
handle.style.display = 'none';
|
||
if (btn) btn.textContent = '›';
|
||
} else {
|
||
const saved = localStorage.getItem('el-ide-tree-w') || '220';
|
||
tree.style.width = saved + 'px';
|
||
tree.style.minWidth = '100px';
|
||
tree.style.overflow = '';
|
||
handle.style.display = '';
|
||
if (btn) btn.textContent = '‹';
|
||
}
|
||
}
|
||
|
||
document.getElementById('btn-collapse-tree').addEventListener('click', () => toggleFileTree());
|
||
document.getElementById('btn-new-file').addEventListener('click', () => {
|
||
const name = prompt('New file name (relative to project root):');
|
||
if (name) createNewFile(name);
|
||
});
|
||
document.getElementById('btn-refresh-tree').addEventListener('click', () => {
|
||
loadFileTree().then(() => loadGitStatus());
|
||
showToast('File tree refreshed');
|
||
});
|
||
|
||
(function initFileTreeResize() {
|
||
const handle = document.getElementById('file-tree-resize');
|
||
const tree = document.getElementById('file-tree');
|
||
let dragging = false;
|
||
let startX = 0;
|
||
let startW = 0;
|
||
|
||
handle.addEventListener('mousedown', e => {
|
||
if (fileTreeCollapsed) return;
|
||
dragging = true;
|
||
startX = e.clientX;
|
||
startW = tree.offsetWidth;
|
||
document.body.style.cursor = 'ew-resize';
|
||
document.body.style.userSelect = 'none';
|
||
e.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('mousemove', e => {
|
||
if (!dragging) return;
|
||
const newW = Math.max(100, Math.min(500, startW + (e.clientX - startX)));
|
||
tree.style.width = newW + 'px';
|
||
tree.style.minWidth = newW + 'px';
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
if (!dragging) return;
|
||
dragging = false;
|
||
document.body.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
localStorage.setItem('el-ide-tree-w', tree.offsetWidth);
|
||
});
|
||
})();
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Type-graph right-panel resize
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
(function initTypeGraphResize() {
|
||
// Insert a resize handle between editor-col and type-graph at runtime
|
||
const typeGraph = document.getElementById('type-graph');
|
||
const mainRow = document.getElementById('main-row');
|
||
if (!typeGraph || !mainRow) return;
|
||
|
||
// Create handle element between editor-col and type-graph
|
||
const handle = document.createElement('div');
|
||
handle.className = 'panel-resize-handle';
|
||
handle.id = 'type-graph-resize';
|
||
handle.style.cursor = 'ew-resize';
|
||
mainRow.insertBefore(handle, typeGraph);
|
||
|
||
let dragging = false;
|
||
let startX = 0;
|
||
let startW = 0;
|
||
|
||
handle.addEventListener('mousedown', e => {
|
||
dragging = true;
|
||
startX = e.clientX;
|
||
startW = typeGraph.offsetWidth;
|
||
document.body.style.cursor = 'ew-resize';
|
||
document.body.style.userSelect = 'none';
|
||
e.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('mousemove', e => {
|
||
if (!dragging) return;
|
||
// Moving handle left increases type-graph width (handle is on the left of type-graph)
|
||
const newW = Math.max(120, Math.min(600, startW + (startX - e.clientX)));
|
||
typeGraph.style.width = newW + 'px';
|
||
typeGraph.style.minWidth = newW + 'px';
|
||
typeGraph.style.flex = 'none';
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
if (!dragging) return;
|
||
dragging = false;
|
||
document.body.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
localStorage.setItem('el-ide-graph-w', typeGraph.offsetWidth);
|
||
});
|
||
})();
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Toggle bottom panel
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
let bottomPanelVisible = true;
|
||
|
||
function toggleBottomPanel() {
|
||
const panel = document.getElementById('bottom-panel');
|
||
const handle = document.getElementById('bottom-resize-handle');
|
||
bottomPanelVisible = !bottomPanelVisible;
|
||
if (bottomPanelVisible) {
|
||
const saved = localStorage.getItem('el-ide-bottom-h') || '180';
|
||
panel.style.height = saved + 'px';
|
||
panel.style.minHeight = saved + 'px';
|
||
panel.style.display = '';
|
||
if (handle) handle.style.display = '';
|
||
} else {
|
||
localStorage.setItem('el-ide-bottom-h', panel.offsetHeight);
|
||
panel.style.display = 'none';
|
||
if (handle) handle.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Select next occurrence (Ctrl+D — add selection of next match)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
function selectNextOccurrence() {
|
||
if (!editor) return;
|
||
const state = editor.state;
|
||
const sel = state.selection;
|
||
const mainSel = sel.main;
|
||
|
||
let word;
|
||
if (mainSel.empty) {
|
||
// No selection — select word under cursor
|
||
const { from, to } = state.wordAt(mainSel.head) || { from: mainSel.head, to: mainSel.head };
|
||
word = state.doc.sliceString(from, to);
|
||
if (!word) return;
|
||
editor.dispatch({ selection: { anchor: from, head: to } });
|
||
return;
|
||
}
|
||
|
||
// Text already selected — find and add next occurrence
|
||
word = state.doc.sliceString(mainSel.from, mainSel.to);
|
||
if (!word) return;
|
||
|
||
const docText = state.doc.toString();
|
||
const searchFrom = mainSel.to;
|
||
let idx = docText.indexOf(word, searchFrom);
|
||
if (idx === -1) idx = docText.indexOf(word, 0); // wrap around
|
||
if (idx === -1 || idx === mainSel.from) return;
|
||
|
||
const { EditorSelection } = window._cm6State || {};
|
||
if (!EditorSelection) {
|
||
// Fallback: just move selection
|
||
editor.dispatch({ selection: { anchor: idx, head: idx + word.length }, scrollIntoView: true });
|
||
return;
|
||
}
|
||
|
||
const newSel = EditorSelection.create([
|
||
...sel.ranges,
|
||
EditorSelection.range(idx, idx + word.length),
|
||
], sel.ranges.length);
|
||
|
||
editor.dispatch({ selection: newSel, scrollIntoView: true });
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Create new file
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function createNewFile(filename) {
|
||
if (!filename) return;
|
||
try {
|
||
const resp = await fetch('/api/file', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: filename, content: '' }),
|
||
});
|
||
if (resp.ok) {
|
||
await loadFileTree();
|
||
openTab(filename, filename.split('/').pop(), '');
|
||
showToast(`Created ${filename}`);
|
||
} else {
|
||
showToast('Failed to create file');
|
||
}
|
||
} catch (e) {
|
||
showToast('Error creating file: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Git Status — fetch changed files and annotate the file tree
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
let gitStatus = {}; // path → status string
|
||
|
||
async function loadGitStatus() {
|
||
try {
|
||
const resp = await fetch('/api/git/status');
|
||
if (!resp.ok) return;
|
||
const files = await resp.json();
|
||
gitStatus = {};
|
||
for (const f of files) {
|
||
gitStatus[f.path] = f.status;
|
||
}
|
||
applyGitBadges();
|
||
} catch { /* silently ignore */ }
|
||
}
|
||
|
||
function applyGitBadges() {
|
||
// Remove any existing badges
|
||
document.querySelectorAll('.git-badge').forEach(b => b.remove());
|
||
for (const [path, status] of Object.entries(gitStatus)) {
|
||
const el = document.querySelector(`.file-item[data-path="${CSS.escape(path)}"]`);
|
||
if (!el) continue;
|
||
const badge = document.createElement('span');
|
||
badge.className = `git-badge git-badge-${status}`;
|
||
badge.textContent = status;
|
||
el.appendChild(badge);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Toast notification
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
let toastTimer = null;
|
||
function showToast(msg, duration = 2000) {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = msg;
|
||
el.classList.add('show');
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => el.classList.remove('show'), duration);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Utilities
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
function escapeHtml(str) {
|
||
return String(str)
|
||
.replace(/&/g,'&')
|
||
.replace(/</g,'<')
|
||
.replace(/>/g,'>')
|
||
.replace(/"/g,'"');
|
||
}
|
||
|
||
function escapeRegex(str) {
|
||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Breadcrumb — wire directory segment clicks to reveal in file tree
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
document.getElementById('breadcrumb').addEventListener('click', e => {
|
||
const crumb = e.target.closest('.crumb');
|
||
if (!crumb) return;
|
||
const dirPath = crumb.dataset.path;
|
||
if (!dirPath) return;
|
||
// Expand/scroll to this directory in the file tree
|
||
const dirItem = document.querySelector(`.file-item.dir[data-path="${CSS.escape(dirPath)}"]`);
|
||
if (dirItem) {
|
||
dirItem.scrollIntoView({ block: 'nearest' });
|
||
// Toggle open if not already
|
||
if (!dirItem.classList.contains('open')) dirItem.click();
|
||
}
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// El version — fetch from /api/status
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function loadElVersion() {
|
||
try {
|
||
const resp = await fetch('/api/status');
|
||
if (!resp.ok) return;
|
||
const s = await resp.json();
|
||
if (s.el_version) {
|
||
const verEl = document.getElementById('status-el-ver');
|
||
const sepEl = document.getElementById('status-el-ver-sep');
|
||
verEl.textContent = 'el ' + s.el_version;
|
||
verEl.title = 'El compiler ' + s.el_version;
|
||
if (sepEl) sepEl.style.display = '';
|
||
}
|
||
} catch { /* silently ignore */ }
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Project config
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function loadProjectConfig() {
|
||
try {
|
||
const resp = await fetch('/api/config');
|
||
if (!resp.ok) return;
|
||
const cfg = await resp.json();
|
||
document.getElementById('project-name-span').textContent = cfg.project_name;
|
||
} catch { /* silently ignore */ }
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Init
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function init() {
|
||
// Restore theme first to avoid flash
|
||
const savedTheme = localStorage.getItem('el-ide-theme') || 'dark';
|
||
applyTheme(savedTheme);
|
||
|
||
restoreSettings();
|
||
resizeCanvas();
|
||
window.addEventListener('resize', () => { resizeCanvas(); drawGraph(); });
|
||
|
||
// Load settings from server (may override localStorage values)
|
||
loadSettingsFromApi();
|
||
loadElVersion();
|
||
|
||
await Promise.all([loadProjectConfig(), loadFileTree(), loadServerSnippets()]);
|
||
loadGitStatus();
|
||
|
||
// Auto-open a sensible entry file
|
||
const candidates = [
|
||
'neuron-lang/memory.el',
|
||
'neuron-lang/neuron.el',
|
||
'neuron-lang/axon.el',
|
||
'src/main.el',
|
||
'main.el',
|
||
];
|
||
|
||
let opened = false;
|
||
for (const candidate of candidates) {
|
||
try {
|
||
const resp = await fetch(`/api/file?path=${encodeURIComponent(candidate)}`);
|
||
if (resp.ok) {
|
||
const { content } = await resp.json();
|
||
const name = candidate.split('/').pop();
|
||
openTab(candidate, name, content);
|
||
highlightFileInTree(candidate);
|
||
await refreshTypeGraph(content);
|
||
opened = true;
|
||
break;
|
||
}
|
||
} catch { /* try next */ }
|
||
}
|
||
|
||
if (!opened) {
|
||
const elItem = document.querySelector('.file-item:not(.dir)[data-path$=".el"]');
|
||
if (elItem) {
|
||
const path = elItem.dataset.path;
|
||
const name = path.split('/').pop();
|
||
openFileFromTree(path, name);
|
||
}
|
||
}
|
||
|
||
drawGraph();
|
||
|
||
// Load status
|
||
try {
|
||
const resp = await fetch('/api/status');
|
||
if (resp.ok) {
|
||
const s = await resp.json();
|
||
document.getElementById('project-name-span').textContent = s.project_name;
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|