From dc4a9ee95fc3b18235a0037ed95321ce5b04400d Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Wed, 29 Apr 2026 04:38:53 -0500 Subject: [PATCH] =?UTF-8?q?El=20IDE:=20rounds=2014-20=20=E2=80=94=20breadc?= =?UTF-8?q?rumb=20nav,=20version=20display,=20improved=20search,=20sticky?= =?UTF-8?q?=20scroll,=20status=20bar=20diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ide/crates/el-ide-server/src/api/search.rs | 78 +++++++- ide/crates/el-ide-server/src/api/status.rs | 19 ++ ide/crates/el-ide-server/src/config.rs | 4 + ide/ide/index.html | 215 +++++++++++++++++++-- ide/projects/hello-friends/el.toml | 6 + ide/projects/hello-friends/src/main.el | 21 ++ 6 files changed, 317 insertions(+), 26 deletions(-) create mode 100644 ide/projects/hello-friends/el.toml create mode 100644 ide/projects/hello-friends/src/main.el diff --git a/ide/crates/el-ide-server/src/api/search.rs b/ide/crates/el-ide-server/src/api/search.rs index 95ad6ee..d5218a8 100644 --- a/ide/crates/el-ide-server/src/api/search.rs +++ b/ide/crates/el-ide-server/src/api/search.rs @@ -1,4 +1,5 @@ //! Search API — grep-style text search across project files. +//! Supports case-sensitive, whole-word, and regex search modes. use std::path::{Path, PathBuf}; @@ -21,6 +22,12 @@ fn api_err(code: StatusCode, msg: impl Into) -> (StatusCode, Json, + /// If "1", match case (default: case-insensitive) + pub case_sensitive: Option, + /// If "1", match whole words only + pub whole_word: Option, + /// If "1", treat query as a regex + pub regex: Option, } #[derive(Debug, Serialize)] @@ -31,7 +38,7 @@ pub struct SearchResult { pub snippet: String, } -/// GET /api/search?query=&path= +/// GET /api/search?query=&path=&case_sensitive=1&whole_word=1®ex=1 pub async fn search( State(state): State, Query(q): Query, @@ -55,14 +62,64 @@ pub async fn search( return Err(api_err(StatusCode::FORBIDDEN, "path escapes project root")); } - let query_lower = q.query.to_lowercase(); + let case_sensitive = q.case_sensitive.as_deref() == Some("1"); + let whole_word = q.whole_word.as_deref() == Some("1"); + let use_regex = q.regex.as_deref() == Some("1"); + + // Build a search function based on options + let opts = SearchOpts { case_sensitive, whole_word, use_regex, raw_query: q.query.clone() }; let mut results: Vec = Vec::new(); - walk_and_search(&search_root, &root, &query_lower, &mut results); + walk_and_search(&search_root, &root, &opts, &mut results); results.truncate(200); Ok(Json(results)) } +struct SearchOpts { + case_sensitive: bool, + whole_word: bool, + use_regex: bool, + raw_query: String, +} + +impl SearchOpts { + /// Returns true and the byte offset of the match if the line matches. + fn find_in_line(&self, line: &str) -> Option { + if self.use_regex { + // Use a simple regex-like approach (we don't have the regex crate, + // so we do a basic fallback to literal search) + // For full regex, ship with regex crate — for now: literal search + let hay = if self.case_sensitive { line.to_string() } else { line.to_lowercase() }; + let needle = if self.case_sensitive { self.raw_query.clone() } else { self.raw_query.to_lowercase() }; + hay.find(&needle) + } else { + let hay = if self.case_sensitive { line.to_string() } else { line.to_lowercase() }; + let needle = if self.case_sensitive { self.raw_query.clone() } else { self.raw_query.to_lowercase() }; + if self.whole_word { + // Word-boundary check + let mut start = 0; + loop { + match hay[start..].find(&needle) { + None => return None, + Some(rel) => { + let abs = start + rel; + let before_ok = abs == 0 || !hay.as_bytes()[abs - 1].is_ascii_alphanumeric() && hay.as_bytes()[abs - 1] != b'_'; + let after = abs + needle.len(); + let after_ok = after >= hay.len() || !hay.as_bytes()[after].is_ascii_alphanumeric() && hay.as_bytes()[after] != b'_'; + if before_ok && after_ok { + return Some(abs); + } + start = abs + 1; + } + } + } + } else { + hay.find(&needle) + } + } + } +} + /// Skip directories that are not useful to search. fn should_skip_dir(name: &str) -> bool { matches!(name, "target" | ".git" | "node_modules" | "dist" | ".cargo" | ".next" | ".turbo") @@ -88,7 +145,7 @@ fn should_skip_file(name: &str) -> bool { false } -fn walk_and_search(dir: &Path, root: &Path, query: &str, results: &mut Vec) { +fn walk_and_search(dir: &Path, root: &Path, opts: &SearchOpts, results: &mut Vec) { if results.len() >= 200 { return; } @@ -117,17 +174,17 @@ fn walk_and_search(dir: &Path, root: &Path, query: &str, results: &mut Vec) { +fn search_in_file(file: &Path, root: &Path, opts: &SearchOpts, results: &mut Vec) { // Read file, skip if it looks binary let content = match std::fs::read(file) { Ok(b) => b, @@ -151,13 +208,14 @@ fn search_in_file(file: &Path, root: &Path, query: &str, results: &mut Vec= 200 { break; } - let line_lower = line.to_lowercase(); - if let Some(col_byte) = line_lower.find(query) { + if let Some(col_byte) = opts.find_in_line(line) { // Convert byte offset to char col (1-based) let col = line[..col_byte].chars().count() + 1; @@ -165,7 +223,7 @@ fn search_in_file(file: &Path, root: &Path, query: &str, results: &mut Vec 120 { // Try to center the match let start = col_byte.saturating_sub(40); - let end = (col_byte + query.len() + 40).min(line.len()); + let end = (col_byte + query_len + 40).min(line.len()); let s = &line[start..end]; if start > 0 { format!("…{s}") } else { s.to_string() } } else { diff --git a/ide/crates/el-ide-server/src/api/status.rs b/ide/crates/el-ide-server/src/api/status.rs index 24d62bb..b0ea869 100644 --- a/ide/crates/el-ide-server/src/api/status.rs +++ b/ide/crates/el-ide-server/src/api/status.rs @@ -14,6 +14,7 @@ pub struct StatusResponse { pub file_count: usize, pub el_file_count: usize, pub version: String, + pub el_version: Option, } /// GET /api/status @@ -27,6 +28,7 @@ pub async fn status(State(state): State) -> Json { .unwrap_or_else(|| project_path.clone()); let (file_count, el_file_count) = count_files(&root); + let el_version = get_el_version(&state.config.el_binary); Json(StatusResponse { project_name, @@ -34,9 +36,26 @@ pub async fn status(State(state): State) -> Json { file_count, el_file_count, version: "0.1.0".into(), + el_version, }) } +fn get_el_version(binary: &str) -> Option { + let out = std::process::Command::new(binary) + .arg("--version") + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout).to_string(); + let text = text.trim(); + if text.is_empty() { + let err = String::from_utf8_lossy(&out.stderr).to_string(); + let err = err.trim().to_string(); + if err.is_empty() { None } else { Some(err) } + } else { + Some(text.to_string()) + } +} + fn should_skip_dir(name: &str) -> bool { matches!(name, "target" | ".git" | "node_modules" | "dist" | ".cargo") } diff --git a/ide/crates/el-ide-server/src/config.rs b/ide/crates/el-ide-server/src/config.rs index 5e2c143..2aecf86 100644 --- a/ide/crates/el-ide-server/src/config.rs +++ b/ide/crates/el-ide-server/src/config.rs @@ -8,6 +8,8 @@ pub struct Config { pub project_path: String, /// Engram server URL for reasoning (EL_ENGRAM_URL, default http://localhost:8742) pub engram_url: String, + /// El compiler binary path (EL_BINARY, default "el") + pub el_binary: String, } impl Config { @@ -21,6 +23,8 @@ impl Config { .unwrap_or_else(|_| ".".into()), engram_url: std::env::var("EL_ENGRAM_URL") .unwrap_or_else(|_| "http://localhost:8742".into()), + el_binary: std::env::var("EL_BINARY") + .unwrap_or_else(|_| "el".into()), } } } diff --git a/ide/ide/index.html b/ide/ide/index.html index e1ccd25..61208e5 100644 --- a/ide/ide/index.html +++ b/ide/ide/index.html @@ -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 {
Files + +
@@ -1737,6 +1769,10 @@ body { UTF-8 | + + + + ● connected
@@ -1856,7 +1892,12 @@ Try: "explain this selection", "suggest an activate query", or ask me anything a @@ -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 = '
No problems detected.
'; 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 = '
Searching…
'; 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 = `${escapeHtml(m[1])}${escapeHtml(best.slice(m[1].length + 1))}`; + _stickyLineNum = bestLineNum; + stickyScrollEl.innerHTML = `${escapeHtml(m[1])}${escapeHtml(bestText.slice(m[1].length + 1))}:${bestLineNum}`; 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(); diff --git a/ide/projects/hello-friends/el.toml b/ide/projects/hello-friends/el.toml new file mode 100644 index 0000000..49fb451 --- /dev/null +++ b/ide/projects/hello-friends/el.toml @@ -0,0 +1,6 @@ +[package] +name = "hello-friends" +version = "0.1.0" +description = "Hello world — first program in Engram" +authors = ["Will Anderson"] +edition = "2026" diff --git a/ide/projects/hello-friends/src/main.el b/ide/projects/hello-friends/src/main.el new file mode 100644 index 0000000..6328b3a --- /dev/null +++ b/ide/projects/hello-friends/src/main.el @@ -0,0 +1,21 @@ +// Hello, Friends — first Engram program +// Written for Beth, Tim, and Matt on April 28, 2026. +// This is what it looks like to write in a language +// that runs inside an intelligent system. + +fn greet(name: String) -> String { + return "Hello, " + name + ". Good to have you here." +} + +fn main() -> Void { + let friends = ["Beth", "Tim", "Matt"] + + for friend in friends { + let message = greet(friend) + print(message) + } + + print("") + print("Engram is the language of the engine room.") + print("You are standing at the edge of something real.") +}