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:
Will Anderson
2026-04-29 04:38:53 -05:00
parent 376fbb41b3
commit 65e74d6474
6 changed files with 317 additions and 26 deletions
+199 -16
View File
@@ -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();