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:
@@ -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®ex=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")
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user