Archived
1172ab6351
Axum HTTP server (port 7771) serving a single-page IDE with CodeMirror 6 syntax highlighting for engram-lang, a force-directed type graph visualizer, LSP (completions, hover, diagnostics), SSE-streamed build/run output, a plugin host with five first-party plugins, and a reasoning panel that proxies to engram-server. 28 tests across three crates, zero warnings.
1467 lines
48 KiB
HTML
1467 lines
48 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>
|
|
: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;
|
|
}
|
|
|
|
* { 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: 16px;
|
|
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); }
|
|
|
|
.project-name {
|
|
font-family: 'DM Mono', monospace;
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.project-name span { color: var(--text); }
|
|
|
|
.header-spacer { flex: 1; }
|
|
|
|
.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: rgba(56,189,248,0.2);
|
|
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); }
|
|
|
|
/* ── 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 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; }
|
|
|
|
.file-item .indent { display: inline-block; }
|
|
|
|
/* ── 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: auto;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.editor-tab {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 0 14px;
|
|
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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.editor-tab.unsaved .dot { display: block; }
|
|
|
|
#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;
|
|
}
|
|
|
|
/* Syntax tokens */
|
|
.tok-keyword { color: var(--accent); }
|
|
.tok-keyword2 { color: var(--accent); font-style: italic; } /* activate, sealed */
|
|
.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;
|
|
}
|
|
|
|
/* ── TYPE GRAPH PANEL ───────────────────────────────────────────────────────── */
|
|
#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; }
|
|
|
|
#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 lines */
|
|
.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);
|
|
}
|
|
|
|
.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; }
|
|
|
|
/* Reasoning */
|
|
.reason-input-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#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;
|
|
}
|
|
|
|
/* Plugins */
|
|
.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); flex: 1; }
|
|
.plugin-desc { font-size: 11px; color: var(--text3); margin-top: 1px; }
|
|
.plugin-meta { display: flex; flex-direction: column; flex: 1; }
|
|
|
|
.plugin-badge {
|
|
padding: 2px 7px;
|
|
border-radius: 10px;
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.05em;
|
|
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); }
|
|
|
|
/* 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; }
|
|
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.15); }
|
|
|
|
/* 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; }
|
|
</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">hello</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>
|
|
|
|
<!-- ═══ BODY ═════════════════════════════════════════════════════════════════ -->
|
|
<div id="body">
|
|
<div id="main-row">
|
|
|
|
<!-- ── FILE TREE ──────────────────────────────────────────────────────── -->
|
|
<div id="file-tree">
|
|
<div class="panel-header">Files</div>
|
|
<div id="file-list">
|
|
<div style="color:var(--text3);padding:12px;font-size:11px;">Loading...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── EDITOR ─────────────────────────────────────────────────────────── -->
|
|
<div id="editor-col">
|
|
<div id="editor-tabs">
|
|
<div class="editor-tab active" id="default-tab">
|
|
<span>main.el</span>
|
|
<div class="dot"></div>
|
|
</div>
|
|
</div>
|
|
<div id="editor-wrapper">
|
|
<div id="cm-editor"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── TYPE GRAPH ─────────────────────────────────────────────────────── -->
|
|
<div id="type-graph">
|
|
<div class="panel-header">Type Graph</div>
|
|
<canvas id="graph-canvas"></canvas>
|
|
<div id="graph-legend">
|
|
<div class="legend-item">
|
|
<div class="legend-dot" style="background:#7a8a9a"></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:#4a5a6a"></div>
|
|
<span>primitive</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div><!-- /#main-row -->
|
|
|
|
<!-- ═══ BOTTOM PANEL ═════════════════════════════════════════════════════ -->
|
|
<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="reasoning">Reasoning</div>
|
|
<div class="bottom-tab" data-panel="plugins">Plugins</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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- Plugins -->
|
|
<div id="panel-plugins" class="bottom-panel-content">
|
|
<div id="plugin-list">Loading...</div>
|
|
</div>
|
|
|
|
</div><!-- /#bottom-content -->
|
|
</div>
|
|
|
|
</div><!-- /#body -->
|
|
|
|
<!-- Hover tooltip -->
|
|
<div class="tooltip" id="tooltip" style="display:none"></div>
|
|
|
|
<script type="module">
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Imports — CodeMirror 6 via ESM CDN
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
import { EditorView, basicSetup } from 'https://esm.sh/codemirror@6.0.1';
|
|
import { keymap } from 'https://esm.sh/@codemirror/view@6';
|
|
import { indentWithTab } from 'https://esm.sh/@codemirror/commands@6';
|
|
import { StreamLanguage } from 'https://esm.sh/@codemirror/language@6';
|
|
import { linter, lintGutter } from 'https://esm.sh/@codemirror/lint@6';
|
|
import { EditorState } from 'https://esm.sh/@codemirror/state@6';
|
|
import { oneDark } from 'https://esm.sh/@codemirror/theme-one-dark@6';
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Engram-lang StreamLanguage definition for CodeMirror 6
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
const KEYWORDS2 = new Set(['activate', 'sealed']); // special keywords
|
|
const KEYWORDS = new Set(['let','fn','type','enum','match','return','where','if','else','for','in','true','false']);
|
|
const BUILTINS = new Set(['Int','Float','String','Bool','Uuid','Void']);
|
|
|
|
const engramLang = StreamLanguage.define({
|
|
name: 'engram',
|
|
token(stream, state) {
|
|
// Skip whitespace
|
|
if (stream.eatSpace()) return null;
|
|
|
|
// Comments
|
|
if (stream.match('//')) {
|
|
stream.skipToEnd();
|
|
return 'comment';
|
|
}
|
|
|
|
// String literals
|
|
if (stream.eat('"')) {
|
|
while (!stream.eol()) {
|
|
if (stream.eat('\\')) { stream.next(); continue; }
|
|
if (stream.eat('"')) break;
|
|
stream.next();
|
|
}
|
|
return 'string';
|
|
}
|
|
|
|
// Numbers
|
|
if (stream.match(/^-?\d+(\.\d+)?/)) return 'number';
|
|
|
|
// Identifiers / keywords
|
|
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';
|
|
// Capitalized identifiers = type names
|
|
if (/^[A-Z]/.test(word)) return 'type';
|
|
return 'variable';
|
|
}
|
|
|
|
// Operators & punctuation
|
|
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: '//' },
|
|
},
|
|
});
|
|
|
|
// Map stream language token names to our CSS classes
|
|
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' },
|
|
// token classes
|
|
'.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)' },
|
|
}, { dark: true });
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// IDE State
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
const state = {
|
|
openFile: null,
|
|
unsaved: false,
|
|
currentSource: '',
|
|
lspDiagnostics: [],
|
|
graphData: { nodes: [], edges: [] },
|
|
graphOffsetX: 0,
|
|
graphOffsetY: 0,
|
|
graphScale: 1,
|
|
graphDragging: false,
|
|
graphDragStart: null,
|
|
graphNodes: [], // {id, x, y, vx, vy, ...}
|
|
graphAnimFrame: null,
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 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();
|
|
state.lspDiagnostics = diags;
|
|
updateProblemsPanel(diags);
|
|
return diags.map(d => ({
|
|
from: 0,
|
|
to: Math.min(source.length, 1),
|
|
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]),
|
|
StreamLanguage.define(engramLang.language.data).of({}),
|
|
engramLang,
|
|
engramTheme(),
|
|
lspLinter,
|
|
lintGutter(),
|
|
EditorView.updateListener.of(update => {
|
|
if (update.docChanged) {
|
|
state.unsaved = true;
|
|
state.currentSource = update.state.doc.toString();
|
|
markTabUnsaved(true);
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => {
|
|
refreshTypeGraph(state.currentSource);
|
|
}, 800);
|
|
}
|
|
}),
|
|
EditorView.domEventHandlers({
|
|
keydown(e, view) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
saveCurrentFile();
|
|
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();
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
|
|
function setEditorContent(text) {
|
|
editor.dispatch({
|
|
changes: { from: 0, to: editor.state.doc.length, insert: text },
|
|
});
|
|
state.unsaved = false;
|
|
markTabUnsaved(false);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 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
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
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();
|
|
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 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 ? '▸' : '▾';
|
|
// Toggle children
|
|
const childContainer = item.nextElementSibling;
|
|
if (childContainer && childContainer.dataset.children) {
|
|
childContainer.style.display = isOpen ? 'none' : '';
|
|
}
|
|
});
|
|
|
|
list.appendChild(item);
|
|
|
|
// Add child container
|
|
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', () => openFile(entry.path, entry.name));
|
|
list.appendChild(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function openFile(path, name) {
|
|
// Mark active
|
|
document.querySelectorAll('.file-item').forEach(el => el.classList.remove('active'));
|
|
document.querySelector(`.file-item[data-path="${CSS.escape(path)}"]`)?.classList.add('active');
|
|
|
|
try {
|
|
const resp = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
|
|
if (!resp.ok) throw new Error(await resp.text());
|
|
const { content } = await resp.json();
|
|
state.openFile = path;
|
|
state.currentSource = content;
|
|
setEditorContent(content);
|
|
|
|
// Update tab
|
|
document.getElementById('default-tab').querySelector('span').textContent = name || path;
|
|
|
|
// Refresh type graph
|
|
refreshTypeGraph(content);
|
|
} catch (err) {
|
|
appendBuildLine('error', `Failed to open ${path}: ${err}`);
|
|
switchBottomTab('build');
|
|
}
|
|
}
|
|
|
|
async function saveCurrentFile() {
|
|
if (!state.openFile) return;
|
|
const content = editor.state.doc.toString();
|
|
try {
|
|
const resp = await fetch('/api/file', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: state.openFile, content }),
|
|
});
|
|
if (!resp.ok) throw new Error(await resp.text());
|
|
state.unsaved = false;
|
|
markTabUnsaved(false);
|
|
appendBuildLine('success', `Saved ${state.openFile}`);
|
|
} catch (err) {
|
|
appendBuildLine('error', `Save failed: ${err}`);
|
|
}
|
|
}
|
|
|
|
function markTabUnsaved(unsaved) {
|
|
const tab = document.getElementById('default-tab');
|
|
tab.classList.toggle('unsaved', unsaved);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Build / Run
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
function clearBuildOutput() {
|
|
document.getElementById('panel-build').innerHTML = '';
|
|
}
|
|
|
|
function appendBuildLine(type, text) {
|
|
const panel = document.getElementById('panel-build');
|
|
const line = document.createElement('span');
|
|
line.className = `out-line ${type}`;
|
|
line.textContent = text;
|
|
panel.appendChild(line);
|
|
panel.scrollTop = panel.scrollHeight;
|
|
}
|
|
|
|
async function runBuildOrRun(action) {
|
|
switchBottomTab('build');
|
|
clearBuildOutput();
|
|
appendBuildLine('info', `Starting ${action}...`);
|
|
|
|
const target = document.getElementById('target-select').value;
|
|
const body = action === 'build'
|
|
? { target, file: state.openFile || undefined }
|
|
: { file: state.openFile || 'src/main.el' };
|
|
|
|
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 });
|
|
|
|
// Parse SSE
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop(); // keep incomplete line
|
|
|
|
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();
|
|
appendBuildLine(eventType, data);
|
|
if (eventType === 'done') {
|
|
const code = parseInt(data, 10);
|
|
appendBuildLine(code === 0 ? 'success' : 'error',
|
|
code === 0 ? '✓ Finished successfully.' : `✗ Exited with code ${code}.`);
|
|
}
|
|
eventType = 'info';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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');
|
|
badge.textContent = errors.length;
|
|
badge.style.display = errors.length > 0 ? '' : '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">
|
|
<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('');
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 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',
|
|
};
|
|
|
|
function nodeColor(kind) {
|
|
return NODE_COLORS[kind] || '#7a8a9a';
|
|
}
|
|
|
|
function resizeCanvas() {
|
|
const rect = canvas.parentElement.getBoundingClientRect();
|
|
// Subtract legend height (~40px)
|
|
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();
|
|
state.graphData = graph;
|
|
initGraphLayout(graph);
|
|
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;
|
|
|
|
state.graphNodes = 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,
|
|
};
|
|
});
|
|
|
|
// Run a few force-layout iterations synchronously
|
|
for (let iter = 0; iter < 60; iter++) {
|
|
tickForce();
|
|
}
|
|
}
|
|
|
|
function tickForce() {
|
|
const nodes = state.graphNodes;
|
|
const edges = state.graphData.edges || [];
|
|
const w = canvas.width || 280;
|
|
const h = canvas.height || 300;
|
|
const k = 80; // spring rest length
|
|
const repulsion = 3000;
|
|
|
|
// Reset forces
|
|
nodes.forEach(n => { n.fx = 0; n.fy = 0; });
|
|
|
|
// Repulsion
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Attraction (edges)
|
|
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;
|
|
}
|
|
|
|
// Center gravity
|
|
nodes.forEach(n => {
|
|
n.fx += (w/2 - n.x) * 0.01;
|
|
n.fy += (h/2 - n.y) * 0.01;
|
|
});
|
|
|
|
// Integrate
|
|
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;
|
|
// Bounds
|
|
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 = state.graphNodes;
|
|
const edges = state.graphData.edges || [];
|
|
const w = canvas.width;
|
|
const h = canvas.height;
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
// Background
|
|
ctx.fillStyle = '#0d1117';
|
|
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;
|
|
}
|
|
|
|
const nodeIndex = Object.fromEntries(nodes.map(n => [n.id, n]));
|
|
|
|
// Draw edges
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
|
ctx.lineWidth = 1;
|
|
|
|
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();
|
|
|
|
// Arrow at target
|
|
const angle = Math.atan2(b.y - a.y, b.x - a.x);
|
|
const arrowLen = 6;
|
|
const tx = b.x - Math.cos(angle) * 12;
|
|
const ty = b.y - Math.sin(angle) * 12;
|
|
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();
|
|
}
|
|
|
|
// Draw nodes
|
|
ctx.font = 'bold 10px DM Mono, monospace';
|
|
ctx.textAlign = 'center';
|
|
|
|
for (const node of nodes) {
|
|
const color = nodeColor(node.kind);
|
|
const r = node.kind === 'builtin' ? 6 : 9;
|
|
|
|
// Node circle
|
|
ctx.beginPath();
|
|
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
|
ctx.fillStyle = color;
|
|
ctx.fill();
|
|
|
|
// Glow for user types
|
|
if (node.kind !== 'builtin' && node.kind !== 'primitive') {
|
|
ctx.shadowColor = color;
|
|
ctx.shadowBlur = 8;
|
|
ctx.beginPath();
|
|
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
|
ctx.fillStyle = color + '66';
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
|
|
// Label
|
|
ctx.fillStyle = node.kind === 'builtin' ? '#4a5a6a' : '#e8edf3';
|
|
ctx.fillText(node.name, node.x, node.y - r - 4);
|
|
}
|
|
}
|
|
|
|
// Canvas interaction (pan)
|
|
canvas.addEventListener('mousedown', e => {
|
|
state.graphDragging = true;
|
|
state.graphDragStart = { x: e.clientX, y: e.clientY };
|
|
});
|
|
|
|
canvas.addEventListener('mousemove', e => {
|
|
if (!state.graphDragging) return;
|
|
const dx = e.clientX - state.graphDragStart.x;
|
|
const dy = e.clientY - state.graphDragStart.y;
|
|
state.graphDragStart = { x: e.clientX, y: e.clientY };
|
|
state.graphNodes.forEach(n => { n.x += dx; n.y += dy; });
|
|
drawGraph();
|
|
});
|
|
|
|
canvas.addEventListener('mouseup', () => { state.graphDragging = false; });
|
|
canvas.addEventListener('mouseleave', () => { state.graphDragging = false; });
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 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();
|
|
}
|
|
|
|
document.querySelectorAll('.bottom-tab').forEach(tab =>
|
|
tab.addEventListener('click', () => switchBottomTab(tab.dataset.panel))
|
|
);
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Plugins panel
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
async function loadPlugins() {
|
|
const list = document.getElementById('plugin-list');
|
|
try {
|
|
const resp = await fetch('/api/plugins');
|
|
const plugins = await resp.json();
|
|
if (plugins.length === 0) {
|
|
list.innerHTML = '<div style="color:var(--text3)">No plugins.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = plugins.map(p => `
|
|
<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>
|
|
<span class="plugin-badge ${p.installed ? 'installed' : 'available'}">${p.installed ? 'installed' : 'available'}</span>
|
|
${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>`
|
|
}
|
|
</div>
|
|
`).join('');
|
|
} 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 }),
|
|
});
|
|
loadPlugins();
|
|
};
|
|
|
|
window.pluginRemove = async (name) => {
|
|
await fetch(`/api/plugins/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
loadPlugins();
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Reasoning panel
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
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 resp = await fetch('/api/reason', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
hypothesis,
|
|
context: editor.state.doc.toString().slice(0, 2000),
|
|
}),
|
|
});
|
|
const result = await resp.json();
|
|
const verdictClass = result.verdict === 'supported' ? 'supported'
|
|
: result.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}">${result.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();
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Utilities
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
function escapeHtml(str) {
|
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Init
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
async function init() {
|
|
resizeCanvas();
|
|
window.addEventListener('resize', () => { resizeCanvas(); drawGraph(); });
|
|
|
|
// Load file tree
|
|
await loadFileTree();
|
|
|
|
// Try to auto-open src/main.el
|
|
try {
|
|
const resp = await fetch('/api/file?path=src/main.el');
|
|
if (resp.ok) {
|
|
const { content } = await resp.json();
|
|
state.openFile = 'src/main.el';
|
|
state.currentSource = content;
|
|
setEditorContent(content);
|
|
document.querySelector('.file-item[data-path="src/main.el"]')?.classList.add('active');
|
|
await refreshTypeGraph(content);
|
|
}
|
|
} catch { /* no main.el */ }
|
|
|
|
// Initial type graph draw (empty)
|
|
drawGraph();
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|