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
+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 {
+19
View File
@@ -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()),
}
}
}