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 12e537d6ab
commit dc4a9ee95f
6 changed files with 317 additions and 26 deletions
+68 -10
View File
@@ -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<String>) -> (StatusCode, Json<serde_
pub struct SearchQuery {
pub query: String,
pub path: Option<String>,
/// If "1", match case (default: case-insensitive)
pub case_sensitive: Option<String>,
/// If "1", match whole words only
pub whole_word: Option<String>,
/// If "1", treat query as a regex
pub regex: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -31,7 +38,7 @@ pub struct SearchResult {
pub snippet: String,
}
/// GET /api/search?query=<text>&path=<optional-subdir>
/// GET /api/search?query=<text>&path=<optional-subdir>&case_sensitive=1&whole_word=1&regex=1
pub async fn search(
State(state): State<AppState>,
Query(q): Query<SearchQuery>,
@@ -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<SearchResult> = 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<usize> {
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<SearchResult>) {
fn walk_and_search(dir: &Path, root: &Path, opts: &SearchOpts, results: &mut Vec<SearchResult>) {
if results.len() >= 200 {
return;
}
@@ -117,17 +174,17 @@ fn walk_and_search(dir: &Path, root: &Path, query: &str, results: &mut Vec<Searc
if should_skip_dir(&name) {
continue;
}
walk_and_search(&path, root, query, results);
walk_and_search(&path, root, opts, results);
} else {
if should_skip_file(&name) {
continue;
}
search_in_file(&path, root, query, results);
search_in_file(&path, root, opts, results);
}
}
}
fn search_in_file(file: &Path, root: &Path, query: &str, results: &mut Vec<SearchResult>) {
fn search_in_file(file: &Path, root: &Path, opts: &SearchOpts, results: &mut Vec<SearchResult>) {
// 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<Searc
.to_string_lossy()
.to_string();
let query_len = opts.raw_query.len();
for (line_idx, line) in text.lines().enumerate() {
if results.len() >= 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<Searc
let snippet = if line.len() > 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 {
@@ -14,6 +14,7 @@ pub struct StatusResponse {
pub file_count: usize,
pub el_file_count: usize,
pub version: String,
pub el_version: Option<String>,
}
/// GET /api/status
@@ -27,6 +28,7 @@ pub async fn status(State(state): State<AppState>) -> Json<StatusResponse> {
.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<AppState>) -> Json<StatusResponse> {
file_count,
el_file_count,
version: "0.1.0".into(),
el_version,
})
}
fn get_el_version(binary: &str) -> Option<String> {
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")
}
+4
View File
@@ -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()),
}
}
}
+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();
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "hello-friends"
version = "0.1.0"
description = "Hello world — first program in Engram"
authors = ["Will Anderson"]
edition = "2026"
+21
View File
@@ -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.")
}