Archived
El IDE: 10-round pass — syntax highlighting, file browser, runner, completion, split panes, find/replace, settings, minimap
Round 1: Fix dependency paths (../el/crates → ../el/engrams), verify build Round 2: Enhanced syntax highlighting — function call detection, all El keywords (activate, sealed, parallel, deploy, etc.) Round 3: Full El keyword set in CodeMirror tokenizer and completions; 50+ builtin function completions with type signatures Round 4: File system integration — mkdir, rename, delete, file tree search; git status badges Round 5: Runner integration — Ctrl+R shortcut, SSE streaming output, clickable error lines with jump-to-line Round 6: Error highlighting with accurate line/col from lexer/parser spans; diagnostic dedup Round 7: Find/replace panel; Ctrl+G go-to-line; toggle line comment; word-wrap compartment fix Round 8: Code completion — 50+ builtins, keyword completions, snippet completions, server snippet integration Round 9: Resizable panels — file tree drag-resize + collapse (Ctrl+B), type-graph drag-resize, bottom panel toggle (Ctrl+J), width persistence Round 10: Settings API (GET/POST/DELETE /api/settings, ~/.el-ide/settings.json); frontend wired to API with debounced save; theme persistence Round 11: Minimap click-to-jump and drag-to-scroll Round 12: Command palette — added Go To Line, Toggle Word Wrap/Minimap/File Tree/Bottom Panel, font size commands, New File, Select Next Occurrence Round 13: Multi-cursor — Ctrl+D select next occurrence, EditorSelection exposed for multi-range selection
This commit is contained in:
+505
-16
@@ -1015,11 +1015,14 @@ body {
|
||||
top: 0; right: 0;
|
||||
width: 60px;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
pointer-events: all;
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
opacity: 0.5;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
#minimap-container:hover { opacity: 0.9; }
|
||||
#minimap-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -1034,6 +1037,17 @@ body {
|
||||
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);
|
||||
@@ -1683,11 +1697,15 @@ body {
|
||||
|
||||
<!-- ── FILE TREE ──────────────────────────────────────────────────────── -->
|
||||
<div id="file-tree">
|
||||
<div class="panel-header">Files</div>
|
||||
<div class="panel-header" style="display:flex;align-items:center;gap:4px;">
|
||||
<span style="flex:1">Files</span>
|
||||
<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">
|
||||
@@ -1981,7 +1999,9 @@ import { keymap, drawSelection } from 'https://esm.sh/@codemirror/vie
|
||||
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, StateEffect, StateField, RangeSetBuilder, Transaction } from 'https://esm.sh/@codemirror/state@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';
|
||||
@@ -1999,9 +2019,13 @@ let vimExtension = null;
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Engram-lang StreamLanguage definition for CodeMirror 6
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
const KEYWORDS2 = new Set(['activate', 'sealed']);
|
||||
const KEYWORDS = new Set(['let','fn','type','enum','match','return','where','if','else','for','in','true','false','protocol','impl']);
|
||||
const BUILTINS = new Set(['Int','Float','String','Bool','Uuid','Void','List','Map','Option','Result']);
|
||||
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',
|
||||
@@ -2023,6 +2047,8 @@ const engramLang = StreamLanguage.define({
|
||||
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('::')
|
||||
@@ -2060,6 +2086,9 @@ function engramTheme() {
|
||||
'.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 });
|
||||
}
|
||||
|
||||
@@ -2076,6 +2105,17 @@ const ENGRAM_SNIPPETS = [
|
||||
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) {
|
||||
@@ -2374,12 +2414,29 @@ const lspLinter = linter(async (view) => {
|
||||
if (!resp.ok) return [];
|
||||
const diags = await resp.json();
|
||||
updateProblemsPanel(diags);
|
||||
return diags.map(d => ({
|
||||
from: 0,
|
||||
to: Math.min(source.length, 1),
|
||||
severity: d.severity === 'error' ? 'error' : 'warning',
|
||||
message: d.message,
|
||||
}));
|
||||
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 });
|
||||
|
||||
@@ -2490,6 +2547,32 @@ const editor = new EditorView({
|
||||
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) {
|
||||
@@ -2828,6 +2911,15 @@ const IDE_COMMANDS = [
|
||||
{ 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) {
|
||||
@@ -2976,6 +3068,21 @@ document.addEventListener('keydown', e => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -3626,9 +3733,39 @@ function _drawMinimap() {
|
||||
}
|
||||
|
||||
// Hook minimap updates to editor changes
|
||||
const origUpdateListener = editor.contentDOM;
|
||||
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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -3750,6 +3887,58 @@ async function findReferences(pos) {
|
||||
} 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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -4354,6 +4543,9 @@ function applyTheme(name) {
|
||||
body: JSON.stringify({ name }),
|
||||
}).catch(() => {});
|
||||
|
||||
// Persist theme in settings
|
||||
saveSettingsToApi({ theme: name });
|
||||
|
||||
// Redraw graph since canvas colors change
|
||||
drawGraph();
|
||||
}
|
||||
@@ -4393,6 +4585,9 @@ 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') {
|
||||
@@ -4400,32 +4595,109 @@ function applySetting(key, value) {
|
||||
fontSizeVal.textContent = value + 'px';
|
||||
}
|
||||
if (key === 'wordWrap') {
|
||||
// word wrap would need editor extension swap; mark for future
|
||||
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', () => {
|
||||
localStorage.setItem('el-ide-setting-wordWrap', wordWrapCheck.checked ? '1' : '0');
|
||||
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;
|
||||
@@ -4452,6 +4724,23 @@ function restoreSettings() {
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -4672,6 +4961,203 @@ async function formatCurrentFile() {
|
||||
});
|
||||
})();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 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());
|
||||
|
||||
(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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -4754,6 +5240,9 @@ async function init() {
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', () => { resizeCanvas(); drawGraph(); });
|
||||
|
||||
// Load settings from server (may override localStorage values)
|
||||
loadSettingsFromApi();
|
||||
|
||||
await Promise.all([loadProjectConfig(), loadFileTree(), loadServerSnippets()]);
|
||||
loadGitStatus();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user