Archived
El IDE: rounds 14-20 — breadcrumb nav, version display, improved search, sticky scroll, status bar diagnostics
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)
This commit is contained in:
+199
-16
@@ -1168,6 +1168,32 @@ body {
|
||||
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;
|
||||
@@ -1406,7 +1432,11 @@ body {
|
||||
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 */
|
||||
@@ -1699,6 +1729,8 @@ body {
|
||||
<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">
|
||||
@@ -1737,6 +1769,10 @@ body {
|
||||
<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>
|
||||
@@ -1856,7 +1892,12 @@ Try: "explain this selection", "suggest an activate query", or ask me anything a
|
||||
<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>
|
||||
@@ -3222,9 +3263,30 @@ 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;
|
||||
@@ -3254,11 +3316,61 @@ function updateProblemsPanel(diags) {
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Search panel
|
||||
// Search panel — with case/word/regex toggles and replace
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
document.getElementById('btn-search').addEventListener('click', runSearch);
|
||||
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();
|
||||
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() {
|
||||
@@ -3267,7 +3379,11 @@ async function runSearch() {
|
||||
const results = document.getElementById('search-results');
|
||||
results.innerHTML = '<div style="color:var(--text3)">Searching…</div>';
|
||||
try {
|
||||
const resp = await fetch(`/api/search?query=${encodeURIComponent(query)}`);
|
||||
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) {
|
||||
@@ -3770,46 +3886,69 @@ 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 )/;
|
||||
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;
|
||||
const scrollTop = cmScroller.scrollTop;
|
||||
const lineH = 20; // approximate
|
||||
const firstVisibleLine = Math.floor(scrollTop / lineH) + 1;
|
||||
|
||||
// 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 before firstVisibleLine
|
||||
let best = null;
|
||||
for (let i = Math.min(firstVisibleLine - 1, doc.lines); i >= 1; i--) {
|
||||
// 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)) {
|
||||
best = text;
|
||||
bestText = text;
|
||||
bestLineNum = i;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
// Extract kind and name
|
||||
const m = best.match(/^(\w+)\s+(\S+)/);
|
||||
if (bestText) {
|
||||
const m = bestText.match(/^(\w+)\s+(\S+)/);
|
||||
if (m) {
|
||||
stickyScrollEl.innerHTML = `<span class="sticky-kind">${escapeHtml(m[1])}</span>${escapeHtml(best.slice(m[1].length + 1))}`;
|
||||
_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');
|
||||
@@ -4988,6 +5127,14 @@ function toggleFileTree() {
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -5216,6 +5363,41 @@ 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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -5242,6 +5424,7 @@ async function init() {
|
||||
|
||||
// Load settings from server (may override localStorage values)
|
||||
loadSettingsFromApi();
|
||||
loadElVersion();
|
||||
|
||||
await Promise.all([loadProjectConfig(), loadFileTree(), loadServerSnippets()]);
|
||||
loadGitStatus();
|
||||
|
||||
Reference in New Issue
Block a user