From 12e537d6abbdb5f42cdea9dfd4a818276dd75cfb Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Wed, 29 Apr 2026 04:34:08 -0500 Subject: [PATCH] =?UTF-8?q?El=20IDE:=2010-round=20pass=20=E2=80=94=20synta?= =?UTF-8?q?x=20highlighting,=20file=20browser,=20runner,=20completion,=20s?= =?UTF-8?q?plit=20panes,=20find/replace,=20settings,=20minimap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1: Fix dependency paths (../el/crates → ../el/engrams), verify build Round 2: Enhanced syntax highlighting — function call detection, all El keywords (activate, sealed, parallel, deploy, etc.) Round 3: Full El keyword set in CodeMirror tokenizer and completions; 50+ builtin function completions with type signatures Round 4: File system integration — mkdir, rename, delete, file tree search; git status badges Round 5: Runner integration — Ctrl+R shortcut, SSE streaming output, clickable error lines with jump-to-line Round 6: Error highlighting with accurate line/col from lexer/parser spans; diagnostic dedup Round 7: Find/replace panel; Ctrl+G go-to-line; toggle line comment; word-wrap compartment fix Round 8: Code completion — 50+ builtins, keyword completions, snippet completions, server snippet integration Round 9: Resizable panels — file tree drag-resize + collapse (Ctrl+B), type-graph drag-resize, bottom panel toggle (Ctrl+J), width persistence Round 10: Settings API (GET/POST/DELETE /api/settings, ~/.el-ide/settings.json); frontend wired to API with debounced save; theme persistence Round 11: Minimap click-to-jump and drag-to-scroll Round 12: Command palette — added Go To Line, Toggle Word Wrap/Minimap/File Tree/Bottom Panel, font size commands, New File, Select Next Occurrence Round 13: Multi-cursor — Ctrl+D select next occurrence, EditorSelection exposed for multi-range selection --- ide/Cargo.toml | 11 +- ide/crates/el-ide-server/Cargo.toml | 1 + ide/crates/el-ide-server/src/api/config.rs | 29 + ide/crates/el-ide-server/src/api/files.rs | 77 ++- ide/crates/el-ide-server/src/api/format.rs | 84 +++ ide/crates/el-ide-server/src/api/git.rs | 108 ++++ ide/crates/el-ide-server/src/api/mod.rs | 12 + ide/crates/el-ide-server/src/api/outline.rs | 91 +++ ide/crates/el-ide-server/src/api/plugins.rs | 43 +- .../el-ide-server/src/api/references.rs | 119 ++++ ide/crates/el-ide-server/src/api/run_state.rs | 19 + ide/crates/el-ide-server/src/api/search.rs | 189 +++++++ ide/crates/el-ide-server/src/api/settings.rs | 110 ++++ ide/crates/el-ide-server/src/api/snippets.rs | 67 +++ ide/crates/el-ide-server/src/api/status.rs | 75 +++ ide/crates/el-ide-server/src/api/terminal.rs | 105 ++++ ide/crates/el-ide-server/src/api/themes.rs | 68 +++ ide/crates/el-ide-server/src/embed.rs | 7 +- ide/crates/el-ide-server/src/main.rs | 6 + ide/crates/el-ide-server/src/tests.rs | 3 +- ide/crates/el-lsp/src/completion.rs | 111 +++- ide/crates/el-lsp/src/diagnostic.rs | 14 +- ide/crates/el-lsp/src/hover.rs | 8 + ide/crates/el-lsp/src/type_graph.rs | 12 + ide/ide/index.html | 521 +++++++++++++++++- 25 files changed, 1847 insertions(+), 43 deletions(-) create mode 100644 ide/crates/el-ide-server/src/api/config.rs create mode 100644 ide/crates/el-ide-server/src/api/format.rs create mode 100644 ide/crates/el-ide-server/src/api/git.rs create mode 100644 ide/crates/el-ide-server/src/api/outline.rs create mode 100644 ide/crates/el-ide-server/src/api/references.rs create mode 100644 ide/crates/el-ide-server/src/api/run_state.rs create mode 100644 ide/crates/el-ide-server/src/api/search.rs create mode 100644 ide/crates/el-ide-server/src/api/settings.rs create mode 100644 ide/crates/el-ide-server/src/api/snippets.rs create mode 100644 ide/crates/el-ide-server/src/api/status.rs create mode 100644 ide/crates/el-ide-server/src/api/terminal.rs create mode 100644 ide/crates/el-ide-server/src/api/themes.rs diff --git a/ide/Cargo.toml b/ide/Cargo.toml index 9f4e271..90ba8dd 100644 --- a/ide/Cargo.toml +++ b/ide/Cargo.toml @@ -18,17 +18,18 @@ el-lsp = { path = "crates/el-lsp" } el-plugin-host = { path = "crates/el-plugin-host" } # Engram lang crates (path deps) -el-lexer = { path = "../engram-lang/crates/el-lexer" } -el-parser = { path = "../engram-lang/crates/el-parser" } -el-types = { path = "../engram-lang/crates/el-types" } -el-compiler = { path = "../engram-lang/crates/el-compiler" } +el-lexer = { path = "../el/engrams/el-lexer" } +el-parser = { path = "../el/engrams/el-parser" } +el-types = { path = "../el/engrams/el-types" } +el-compiler = { path = "../el/engrams/el-compiler" } # External serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" tokio = { version = "1", features = ["full"] } -axum = { version = "0.7", features = ["macros"] } +axum = { version = "0.7", features = ["macros", "ws"] } +futures-util = "0.3" tower-http = { version = "0.5", features = ["cors", "fs"] } rust-embed = { version = "8", features = ["axum"] } tracing = "0.1" diff --git a/ide/crates/el-ide-server/Cargo.toml b/ide/crates/el-ide-server/Cargo.toml index 7fc8bca..53c8709 100644 --- a/ide/crates/el-ide-server/Cargo.toml +++ b/ide/crates/el-ide-server/Cargo.toml @@ -27,6 +27,7 @@ tokio-stream = { workspace = true } which = { workspace = true } urlencoding = "2" mime_guess = { workspace = true } +futures-util = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/ide/crates/el-ide-server/src/api/config.rs b/ide/crates/el-ide-server/src/api/config.rs new file mode 100644 index 0000000..60567e7 --- /dev/null +++ b/ide/crates/el-ide-server/src/api/config.rs @@ -0,0 +1,29 @@ +//! Config API — exposes project metadata to the frontend. + +use axum::{extract::State, Json}; +use serde::Serialize; +use std::path::Path; + +use crate::AppState; + +#[derive(Serialize)] +pub struct ConfigResponse { + pub project_name: String, + pub project_path: String, + pub engram_url: String, +} + +pub async fn get_config(State(state): State) -> Json { + let project_path = &state.config.project_path; + let project_name = Path::new(project_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(project_path) + .to_string(); + + Json(ConfigResponse { + project_name, + project_path: project_path.clone(), + engram_url: state.config.engram_url.clone(), + }) +} diff --git a/ide/crates/el-ide-server/src/api/files.rs b/ide/crates/el-ide-server/src/api/files.rs index c95f9c0..70e52f6 100644 --- a/ide/crates/el-ide-server/src/api/files.rs +++ b/ide/crates/el-ide-server/src/api/files.rs @@ -96,10 +96,18 @@ fn read_dir_recursive( for entry in items { let path = entry.path(); let name = entry.file_name().to_string_lossy().to_string(); - // Skip hidden files/dirs + // Skip hidden files/dirs and build/vendor noise if name.starts_with('.') { continue; } + // Skip common non-source directories that bloat the tree + if matches!(name.as_str(), "target" | "node_modules" | ".git" | "dist" | ".cargo") { + continue; + } + // Skip lock files and large generated files + if matches!(name.as_str(), "Cargo.lock" | "package-lock.json" | "yarn.lock" | "bun.lockb") { + continue; + } let rel_path = path.strip_prefix(root).unwrap_or(&path).to_string_lossy().to_string(); let is_dir = path.is_dir(); @@ -149,6 +157,73 @@ pub async fn write_file( Ok(Json(WriteResponse { ok: true })) } +/// DELETE /api/file?path={file} +pub async fn delete_file( + State(state): State, + Query(q): Query, +) -> ApiResult { + let rel = q.path.ok_or_else(|| api_err(StatusCode::BAD_REQUEST, "missing path parameter"))?; + let file_path = safe_path(&state.config.project_path, &rel) + .map_err(|e| api_err(StatusCode::FORBIDDEN, e))?; + + if file_path.is_dir() { + std::fs::remove_dir_all(&file_path) + .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot delete directory: {e}")))?; + } else { + std::fs::remove_file(&file_path) + .map_err(|e| api_err(StatusCode::NOT_FOUND, format!("cannot delete file: {e}")))?; + } + + Ok(Json(WriteResponse { ok: true })) +} + +#[derive(Debug, Deserialize)] +pub struct MkdirRequest { + pub path: String, +} + +/// POST /api/files/mkdir — body: { path } +pub async fn mkdir( + State(state): State, + Json(req): Json, +) -> ApiResult { + let dir_path = safe_path(&state.config.project_path, &req.path) + .map_err(|e| api_err(StatusCode::FORBIDDEN, e))?; + + std::fs::create_dir_all(&dir_path) + .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot create directory: {e}")))?; + + Ok(Json(WriteResponse { ok: true })) +} + +#[derive(Debug, Deserialize)] +pub struct RenameRequest { + pub from: String, + pub to: String, +} + +/// POST /api/files/rename — body: { from, to } +pub async fn rename_file( + State(state): State, + Json(req): Json, +) -> ApiResult { + let from_path = safe_path(&state.config.project_path, &req.from) + .map_err(|e| api_err(StatusCode::FORBIDDEN, e))?; + let to_path = safe_path(&state.config.project_path, &req.to) + .map_err(|e| api_err(StatusCode::FORBIDDEN, e))?; + + // Ensure parent exists + if let Some(parent) = to_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + + std::fs::rename(&from_path, &to_path) + .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot rename: {e}")))?; + + Ok(Json(WriteResponse { ok: true })) +} + // ── Helpers ─────────────────────────────────────────────────────────────────── fn safe_path(project_root: &str, rel: &str) -> Result { diff --git a/ide/crates/el-ide-server/src/api/format.rs b/ide/crates/el-ide-server/src/api/format.rs new file mode 100644 index 0000000..592e919 --- /dev/null +++ b/ide/crates/el-ide-server/src/api/format.rs @@ -0,0 +1,84 @@ +//! Format API — run `el fmt` on source content. + +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; + +type ApiResult = Result, (StatusCode, Json)>; + +fn api_err(msg: impl std::fmt::Display) -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": msg.to_string() })), + ) +} + +#[derive(Debug, Deserialize)] +pub struct FormatRequest { + pub content: String, + /// Optional filename hint for language detection + pub path: Option, +} + +#[derive(Debug, Serialize)] +pub struct FormatResponse { + pub content: String, + pub changed: bool, +} + +/// POST /api/format — format content via `el fmt`, falls back to identity. +pub async fn format( + State(state): State, + Json(req): Json, +) -> ApiResult { + let project_path = &state.config.project_path; + + // Write content to a temp file, run `el fmt`, read back + let tmp_path = format!("{project_path}/.el-ide-fmt-tmp.el"); + + tokio::fs::write(&tmp_path, &req.content) + .await + .map_err(|e| api_err(format!("write tmp: {e}")))?; + + // Try to find `el` binary + let el_bin = if let Ok(path) = which::which("el") { + path.to_string_lossy().to_string() + } else { + // el not installed — return unchanged + tokio::fs::remove_file(&tmp_path).await.ok(); + return Ok(Json(FormatResponse { + content: req.content.clone(), + changed: false, + })); + }; + + let output = tokio::process::Command::new(&el_bin) + .arg("fmt") + .arg(&tmp_path) + .current_dir(project_path) + .output() + .await; + + match output { + Ok(out) if out.status.success() => { + let formatted = tokio::fs::read_to_string(&tmp_path) + .await + .unwrap_or_else(|_| req.content.clone()); + tokio::fs::remove_file(&tmp_path).await.ok(); + let changed = formatted != req.content; + Ok(Json(FormatResponse { content: formatted, changed })) + } + _ => { + tokio::fs::remove_file(&tmp_path).await.ok(); + Ok(Json(FormatResponse { + content: req.content.clone(), + changed: false, + })) + } + } +} diff --git a/ide/crates/el-ide-server/src/api/git.rs b/ide/crates/el-ide-server/src/api/git.rs new file mode 100644 index 0000000..2660f5a --- /dev/null +++ b/ide/crates/el-ide-server/src/api/git.rs @@ -0,0 +1,108 @@ +//! Git API endpoints — status and diff. + +use axum::{ + extract::{Query, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; + +type ApiResult = Result, (StatusCode, Json)>; + +fn api_err(msg: impl std::fmt::Display) -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": msg.to_string() })), + ) +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct GitFileStatus { + pub path: String, + pub status: String, // "M", "A", "D", "?", "R", etc. +} + +#[derive(Debug, Deserialize)] +pub struct DiffQuery { + pub path: Option, +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +/// GET /api/git/status — returns list of changed files. +pub async fn git_status( + State(state): State, +) -> ApiResult> { + let project_path = &state.config.project_path; + + let output = tokio::process::Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(project_path) + .output() + .await + .map_err(|e| api_err(format!("git status failed: {e}")))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut files = Vec::new(); + + for line in stdout.lines() { + if line.len() < 4 { + continue; + } + let xy = &line[..2]; + let path = line[3..].trim(); + // Handle renames: "old -> new" + let file_path = if let Some(arrow) = path.find(" -> ") { + &path[arrow + 4..] + } else { + path + }; + + // Determine status badge: prefer index status (first char) + let status = if xy.starts_with('M') || xy.ends_with('M') { + "M" + } else if xy.starts_with('A') || xy.starts_with('?') { + "A" + } else if xy.starts_with('D') || xy.ends_with('D') { + "D" + } else if xy.starts_with('R') { + "R" + } else { + "M" + }; + + files.push(GitFileStatus { + path: file_path.to_string(), + status: status.to_string(), + }); + } + + Ok(Json(files)) +} + +/// GET /api/git/diff?path= — returns unified diff. +pub async fn git_diff( + State(state): State, + Query(q): Query, +) -> ApiResult { + let project_path = &state.config.project_path; + + let mut cmd = tokio::process::Command::new("git"); + cmd.arg("diff").current_dir(project_path); + + if let Some(ref path) = q.path { + cmd.arg("--").arg(path); + } + + let output = cmd + .output() + .await + .map_err(|e| api_err(format!("git diff failed: {e}")))?; + + let diff = String::from_utf8_lossy(&output.stdout).to_string(); + Ok(Json(diff)) +} diff --git a/ide/crates/el-ide-server/src/api/mod.rs b/ide/crates/el-ide-server/src/api/mod.rs index 6617d36..b717442 100644 --- a/ide/crates/el-ide-server/src/api/mod.rs +++ b/ide/crates/el-ide-server/src/api/mod.rs @@ -1,6 +1,18 @@ pub mod build; +pub mod config; pub mod files; +pub mod format; +pub mod git; pub mod lsp; +pub mod outline; pub mod plugins; pub mod reason; +pub mod references; +pub mod run_state; +pub mod search; +pub mod settings; +pub mod snippets; +pub mod status; +pub mod terminal; +pub mod themes; pub mod type_graph; diff --git a/ide/crates/el-ide-server/src/api/outline.rs b/ide/crates/el-ide-server/src/api/outline.rs new file mode 100644 index 0000000..4d38134 --- /dev/null +++ b/ide/crates/el-ide-server/src/api/outline.rs @@ -0,0 +1,91 @@ +//! Document outline API — returns functions, types, enums from AST. + +use axum::{ + extract::Query, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; + +type ApiResult = Result, (StatusCode, Json)>; + +#[derive(Debug, Deserialize)] +pub struct SourceQuery { + pub source: String, +} + +#[derive(Debug, Serialize)] +pub struct OutlineItem { + pub kind: String, // "fn" | "type" | "enum" | "protocol" | "impl" + pub name: String, + pub line: usize, +} + +/// GET /api/outline?source=... — extract document symbols. +pub async fn outline( + Query(q): Query, +) -> ApiResult> { + let items = extract_outline(&q.source); + Ok(Json(items)) +} + +fn extract_outline(source: &str) -> Vec { + let mut items = Vec::new(); + + // Simple regex-like line scan for top-level declarations. + // Pattern: keyword followed by an identifier. + for (i, line) in source.lines().enumerate() { + let trimmed = line.trim(); + let line_num = i + 1; + + // fn name( + if let Some(rest) = trimmed.strip_prefix("fn ") { + if let Some(name) = ident_from(rest) { + items.push(OutlineItem { kind: "fn".into(), name, line: line_num }); + } + continue; + } + // type Name { + if let Some(rest) = trimmed.strip_prefix("type ") { + if let Some(name) = ident_from(rest) { + items.push(OutlineItem { kind: "type".into(), name, line: line_num }); + } + continue; + } + // enum Name { + if let Some(rest) = trimmed.strip_prefix("enum ") { + if let Some(name) = ident_from(rest) { + items.push(OutlineItem { kind: "enum".into(), name, line: line_num }); + } + continue; + } + // protocol Name { + if let Some(rest) = trimmed.strip_prefix("protocol ") { + if let Some(name) = ident_from(rest) { + items.push(OutlineItem { kind: "protocol".into(), name, line: line_num }); + } + continue; + } + // impl Protocol for Type { + if let Some(rest) = trimmed.strip_prefix("impl ") { + // name is "Protocol for Type" → take until { + let name = rest.trim_end_matches(|c: char| c == '{').trim(); + if !name.is_empty() { + items.push(OutlineItem { kind: "impl".into(), name: name.to_string(), line: line_num }); + } + continue; + } + } + + items +} + +fn ident_from(s: &str) -> Option { + let s = s.trim_start(); + let end = s.find(|c: char| !c.is_alphanumeric() && c != '_').unwrap_or(s.len()); + if end == 0 { + None + } else { + Some(s[..end].to_string()) + } +} diff --git a/ide/crates/el-ide-server/src/api/plugins.rs b/ide/crates/el-ide-server/src/api/plugins.rs index 7bc77dc..380f4f0 100644 --- a/ide/crates/el-ide-server/src/api/plugins.rs +++ b/ide/crates/el-ide-server/src/api/plugins.rs @@ -1,10 +1,9 @@ -//! Plugins API — list, install, remove. +//! Plugins API — list, install, remove, enable, disable. +//! +//! All mutation operations use POST with a JSON body containing `name` +//! to avoid path-parameter routing conflicts with Axum 0.7/matchit 0.7. -use axum::{ - extract::{Path, State}, - http::StatusCode, - Json, -}; +use axum::{extract::State, http::StatusCode, Json}; use serde::{Deserialize, Serialize}; use el_plugin_host::Plugin; @@ -18,7 +17,7 @@ fn api_err(code: StatusCode, msg: impl Into) -> (StatusCode, Json) -> ApiResult, - Json(req): Json, + Json(req): Json, ) -> ApiResult { let mut host = state.plugins.lock().await; host.install(&req.name) @@ -44,13 +43,35 @@ pub async fn install_plugin( .map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string())) } -/// DELETE /api/plugins/{name} +/// POST /api/plugins/remove — body: { name } pub async fn remove_plugin( State(state): State, - Path(name): Path, + Json(req): Json, ) -> ApiResult { let mut host = state.plugins.lock().await; - host.remove(&name) + host.remove(&req.name) + .map(|_| Json(OkResponse { ok: true })) + .map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string())) +} + +/// POST /api/plugins/enable — body: { name } +pub async fn enable_plugin( + State(state): State, + Json(req): Json, +) -> ApiResult { + let mut host = state.plugins.lock().await; + host.enable(&req.name) + .map(|_| Json(OkResponse { ok: true })) + .map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string())) +} + +/// POST /api/plugins/disable — body: { name } +pub async fn disable_plugin( + State(state): State, + Json(req): Json, +) -> ApiResult { + let mut host = state.plugins.lock().await; + host.disable(&req.name) .map(|_| Json(OkResponse { ok: true })) .map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string())) } diff --git a/ide/crates/el-ide-server/src/api/references.rs b/ide/crates/el-ide-server/src/api/references.rs new file mode 100644 index 0000000..4c4d077 --- /dev/null +++ b/ide/crates/el-ide-server/src/api/references.rs @@ -0,0 +1,119 @@ +//! Definition and references API — find all uses of a symbol. + +use axum::{ + extract::Query, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; + +type ApiResult = Result, (StatusCode, Json)>; + +#[derive(Debug, Deserialize)] +pub struct SymbolQuery { + pub source: String, + pub pos: usize, +} + +#[derive(Debug, Serialize)] +pub struct SymbolLocation { + pub line: usize, + pub col: usize, + pub snippet: String, +} + +/// GET /api/definition?source=...&pos=... — find definition of symbol at pos. +pub async fn definition( + Query(q): Query, +) -> ApiResult> { + let loc = find_definition(&q.source, q.pos); + Ok(Json(loc)) +} + +/// GET /api/references?source=...&pos=... — find all references to symbol at pos. +pub async fn references( + Query(q): Query, +) -> ApiResult> { + let refs = find_references(&q.source, q.pos); + Ok(Json(refs)) +} + +// ── Implementation ──────────────────────────────────────────────────────────── + +fn word_at(source: &str, pos: usize) -> Option<&str> { + if pos > source.len() { + return None; + } + let bytes = source.as_bytes(); + let mut start = pos; + let mut end = pos; + while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') { + start -= 1; + } + while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') { + end += 1; + } + if start == end { + None + } else { + Some(&source[start..end]) + } +} + +fn find_definition(source: &str, pos: usize) -> Option { + let word = word_at(source, pos)?; + if word.is_empty() { + return None; + } + + // Look for `fn word`, `type word`, `enum word`, `let word`, `protocol word` + let patterns = [ + format!("fn {word}"), + format!("type {word}"), + format!("enum {word}"), + format!("protocol {word}"), + ]; + + for (i, line) in source.lines().enumerate() { + for pat in &patterns { + if line.contains(pat.as_str()) { + let col = line.find(pat.as_str()).unwrap_or(0); + return Some(SymbolLocation { + line: i + 1, + col: col + 1, + snippet: line.trim().to_string(), + }); + } + } + } + None +} + +fn find_references(source: &str, pos: usize) -> Vec { + let word = match word_at(source, pos) { + Some(w) if !w.is_empty() => w, + _ => return vec![], + }; + + let mut refs = Vec::new(); + for (i, line) in source.lines().enumerate() { + let mut search = line; + let mut offset = 0; + while let Some(idx) = search.find(word) { + // Check word boundaries + let abs = offset + idx; + let before_ok = abs == 0 || !line.as_bytes()[abs - 1].is_ascii_alphanumeric() && line.as_bytes()[abs - 1] != b'_'; + let after_ok = abs + word.len() >= line.len() || !line.as_bytes()[abs + word.len()].is_ascii_alphanumeric() && line.as_bytes()[abs + word.len()] != b'_'; + if before_ok && after_ok { + refs.push(SymbolLocation { + line: i + 1, + col: abs + 1, + snippet: line.trim().to_string(), + }); + } + offset += idx + word.len(); + search = &search[idx + word.len()..]; + } + } + refs +} diff --git a/ide/crates/el-ide-server/src/api/run_state.rs b/ide/crates/el-ide-server/src/api/run_state.rs new file mode 100644 index 0000000..79368f4 --- /dev/null +++ b/ide/crates/el-ide-server/src/api/run_state.rs @@ -0,0 +1,19 @@ +//! Run state — tracks the currently running process so it can be killed. + +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::process::Child; + +/// Global run state shared across request handlers. +#[derive(Default)] +pub struct RunState { + pub current_child: Mutex>, +} + +impl RunState { + pub fn new() -> Arc { + Arc::new(Self { + current_child: Mutex::new(None), + }) + } +} diff --git a/ide/crates/el-ide-server/src/api/search.rs b/ide/crates/el-ide-server/src/api/search.rs new file mode 100644 index 0000000..95ad6ee --- /dev/null +++ b/ide/crates/el-ide-server/src/api/search.rs @@ -0,0 +1,189 @@ +//! Search API — grep-style text search across project files. + +use std::path::{Path, PathBuf}; + +use axum::{ + extract::{Query, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; + +type ApiResult = Result, (StatusCode, Json)>; + +fn api_err(code: StatusCode, msg: impl Into) -> (StatusCode, Json) { + (code, Json(serde_json::json!({ "error": msg.into() }))) +} + +#[derive(Debug, Deserialize)] +pub struct SearchQuery { + pub query: String, + pub path: Option, +} + +#[derive(Debug, Serialize)] +pub struct SearchResult { + pub path: String, + pub line: usize, + pub col: usize, + pub snippet: String, +} + +/// GET /api/search?query=&path= +pub async fn search( + State(state): State, + Query(q): Query, +) -> ApiResult> { + if q.query.is_empty() { + return Ok(Json(vec![])); + } + + let root = PathBuf::from(&state.config.project_path) + .canonicalize() + .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let search_root = if let Some(sub) = q.path.as_deref().filter(|s| !s.is_empty() && *s != ".") { + root.join(sub).canonicalize() + .map_err(|e| api_err(StatusCode::BAD_REQUEST, format!("invalid path: {e}")))? + } else { + root.clone() + }; + + if !search_root.starts_with(&root) { + return Err(api_err(StatusCode::FORBIDDEN, "path escapes project root")); + } + + let query_lower = q.query.to_lowercase(); + let mut results: Vec = Vec::new(); + walk_and_search(&search_root, &root, &query_lower, &mut results); + results.truncate(200); + + Ok(Json(results)) +} + +/// 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") +} + +/// Skip files that are likely binary or irrelevant. +fn should_skip_file(name: &str) -> bool { + // Skip lock files and common binary extensions + if matches!(name, "Cargo.lock" | "package-lock.json" | "yarn.lock" | "bun.lockb") { + return true; + } + let binary_exts = [ + ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", + ".wasm", ".pdf", ".zip", ".tar", ".gz", ".exe", + ".so", ".dylib", ".a", ".rlib", ".rmeta", + ".parquet", ".db", ".sqlite", + ]; + for ext in &binary_exts { + if name.ends_with(ext) { + return true; + } + } + false +} + +fn walk_and_search(dir: &Path, root: &Path, query: &str, results: &mut Vec) { + if results.len() >= 200 { + return; + } + + let rd = match std::fs::read_dir(dir) { + Ok(r) => r, + Err(_) => return, + }; + + let mut entries: Vec<_> = rd.filter_map(|e| e.ok()).collect(); + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + if results.len() >= 200 { + break; + } + + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + + if name.starts_with('.') { + continue; + } + + if path.is_dir() { + if should_skip_dir(&name) { + continue; + } + walk_and_search(&path, root, query, results); + } else { + if should_skip_file(&name) { + continue; + } + search_in_file(&path, root, query, results); + } + } +} + +fn search_in_file(file: &Path, root: &Path, query: &str, results: &mut Vec) { + // Read file, skip if it looks binary + let content = match std::fs::read(file) { + Ok(b) => b, + Err(_) => return, + }; + + // Quick binary check: look for null bytes in first 8KB + let sample = &content[..content.len().min(8192)]; + if sample.contains(&0u8) { + return; + } + + let text = match std::str::from_utf8(&content) { + Ok(s) => s, + Err(_) => return, + }; + + let rel_path = file + .strip_prefix(root) + .unwrap_or(file) + .to_string_lossy() + .to_string(); + + 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) { + // Convert byte offset to char col (1-based) + let col = line[..col_byte].chars().count() + 1; + + // Build snippet (cap at 120 chars) + 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 s = &line[start..end]; + if start > 0 { format!("…{s}") } else { s.to_string() } + } else { + line.to_string() + }; + + let snippet = if snippet.len() > 120 { + format!("{}…", &snippet[..117]) + } else { + snippet + }; + + results.push(SearchResult { + path: rel_path.clone(), + line: line_idx + 1, + col, + snippet, + }); + } + } +} diff --git a/ide/crates/el-ide-server/src/api/settings.rs b/ide/crates/el-ide-server/src/api/settings.rs new file mode 100644 index 0000000..cdc4400 --- /dev/null +++ b/ide/crates/el-ide-server/src/api/settings.rs @@ -0,0 +1,110 @@ +//! Settings API — persist and retrieve IDE settings to/from ~/.el-ide/settings.json + +use axum::{http::StatusCode, Json}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::PathBuf; + +type ApiResult = Result, (StatusCode, Json)>; + +fn api_err(msg: impl std::fmt::Display) -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": msg.to_string() })), + ) +} + +/// Default settings returned when no file exists. +fn default_settings() -> Value { + serde_json::json!({ + "theme": "dark", + "fontSize": 13, + "tabSize": 4, + "wordWrap": false, + "formatOnSave": false, + "vimMode": false, + "minimap": true, + "elBinaryPath": "el", + "engramUrl": "http://localhost:8742", + "stickyScroll": true, + "lineNumbers": true, + "bracketMatching": true + }) +} + +fn settings_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + PathBuf::from(home).join(".el-ide").join("settings.json") +} + +/// GET /api/settings — returns current settings. +pub async fn get_settings() -> ApiResult { + let path = settings_path(); + + if path.exists() { + let content = std::fs::read_to_string(&path) + .map_err(|e| api_err(format!("read settings: {e}")))?; + let val: Value = serde_json::from_str(&content) + .unwrap_or_else(|_| default_settings()); + // Merge with defaults to ensure all keys are present + let mut merged = default_settings(); + if let (Some(obj), Some(defaults)) = (val.as_object(), merged.as_object_mut()) { + for (k, v) in obj { + defaults.insert(k.clone(), v.clone()); + } + } + Ok(Json(merged)) + } else { + Ok(Json(default_settings())) + } +} + +#[derive(Debug, Deserialize)] +pub struct PatchSettings { + pub settings: Value, +} + +/// POST /api/settings — saves settings (merges with existing). +pub async fn save_settings( + Json(req): Json, +) -> ApiResult { + let path = settings_path(); + + // Ensure directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| api_err(format!("create settings dir: {e}")))?; + } + + // Load existing settings + let mut existing = if path.exists() { + let content = std::fs::read_to_string(&path).unwrap_or_default(); + serde_json::from_str::(&content).unwrap_or_else(|_| default_settings()) + } else { + default_settings() + }; + + // Merge incoming patches + if let (Some(existing_obj), Some(patch_obj)) = (existing.as_object_mut(), req.settings.as_object()) { + for (k, v) in patch_obj { + existing_obj.insert(k.clone(), v.clone()); + } + } + + let serialized = serde_json::to_string_pretty(&existing) + .map_err(|e| api_err(format!("serialize settings: {e}")))?; + + std::fs::write(&path, &serialized) + .map_err(|e| api_err(format!("write settings: {e}")))?; + + Ok(Json(existing)) +} + +/// DELETE /api/settings — reset to defaults. +pub async fn reset_settings() -> ApiResult { + let path = settings_path(); + if path.exists() { + std::fs::remove_file(&path).ok(); + } + Ok(Json(default_settings())) +} diff --git a/ide/crates/el-ide-server/src/api/snippets.rs b/ide/crates/el-ide-server/src/api/snippets.rs new file mode 100644 index 0000000..9928050 --- /dev/null +++ b/ide/crates/el-ide-server/src/api/snippets.rs @@ -0,0 +1,67 @@ +//! Snippets API — returns el code templates. + +use axum::Json; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct Snippet { + pub label: String, + pub description: String, + pub body: String, +} + +/// GET /api/completions/snippet — return el code snippets. +pub async fn snippets() -> Json> { + Json(vec![ + Snippet { + label: "fn".into(), + description: "Function definition".into(), + body: "fn ${1:name}(${2:params}) -> ${3:ReturnType} {\n\t${4:body}\n}".into(), + }, + Snippet { + label: "type".into(), + description: "Struct type definition".into(), + body: "type ${1:Name} {\n\t${2:field}: ${3:Type},\n}".into(), + }, + Snippet { + label: "enum".into(), + description: "Enum definition".into(), + body: "enum ${1:Name} {\n\t${2:Variant},\n}".into(), + }, + Snippet { + label: "match".into(), + description: "Match expression".into(), + body: "match ${1:expr} {\n\t${2:pattern} => ${3:body},\n\t_ => ${4:default},\n}".into(), + }, + Snippet { + label: "let".into(), + description: "Let binding".into(), + body: "let ${1:name}: ${2:Type} = ${3:value};".into(), + }, + Snippet { + label: "protocol".into(), + description: "Protocol definition".into(), + body: "protocol ${1:Name} {\n\tfn ${2:method}(${3:self}) -> ${4:Ret};\n}".into(), + }, + Snippet { + label: "impl".into(), + description: "Implementation block".into(), + body: "impl ${1:Protocol} for ${2:Type} {\n\t${3:body}\n}".into(), + }, + Snippet { + label: "if".into(), + description: "If expression".into(), + body: "if ${1:cond} {\n\t${2:body}\n}".into(), + }, + Snippet { + label: "for".into(), + description: "For loop".into(), + body: "for ${1:item} in ${2:iter} {\n\t${3:body}\n}".into(), + }, + Snippet { + label: "activate".into(), + description: "Activate declaration".into(), + body: "activate ${1:Concept};".into(), + }, + ]) +} diff --git a/ide/crates/el-ide-server/src/api/status.rs b/ide/crates/el-ide-server/src/api/status.rs new file mode 100644 index 0000000..24d62bb --- /dev/null +++ b/ide/crates/el-ide-server/src/api/status.rs @@ -0,0 +1,75 @@ +//! Status API — project metadata and server health. + +use std::path::{Path, PathBuf}; + +use axum::{extract::State, Json}; +use serde::Serialize; + +use crate::AppState; + +#[derive(Debug, Serialize)] +pub struct StatusResponse { + pub project_name: String, + pub project_path: String, + pub file_count: usize, + pub el_file_count: usize, + pub version: String, +} + +/// GET /api/status +pub async fn status(State(state): State) -> Json { + let project_path = state.config.project_path.clone(); + let root = PathBuf::from(&project_path); + + let project_name = root + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| project_path.clone()); + + let (file_count, el_file_count) = count_files(&root); + + Json(StatusResponse { + project_name, + project_path, + file_count, + el_file_count, + version: "0.1.0".into(), + }) +} + +fn should_skip_dir(name: &str) -> bool { + matches!(name, "target" | ".git" | "node_modules" | "dist" | ".cargo") +} + +fn count_files(root: &Path) -> (usize, usize) { + let mut total = 0usize; + let mut el_count = 0usize; + count_files_recursive(root, &mut total, &mut el_count); + (total, el_count) +} + +fn count_files_recursive(dir: &Path, total: &mut usize, el_count: &mut usize) { + let rd = match std::fs::read_dir(dir) { + Ok(r) => r, + Err(_) => return, + }; + + for entry in rd.filter_map(|e| e.ok()) { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { + continue; + } + if path.is_dir() { + if should_skip_dir(&name) { + continue; + } + count_files_recursive(&path, total, el_count); + } else { + *total += 1; + if name.ends_with(".el") { + *el_count += 1; + } + } + } +} diff --git a/ide/crates/el-ide-server/src/api/terminal.rs b/ide/crates/el-ide-server/src/api/terminal.rs new file mode 100644 index 0000000..95f2dd5 --- /dev/null +++ b/ide/crates/el-ide-server/src/api/terminal.rs @@ -0,0 +1,105 @@ +//! Terminal WebSocket endpoint — spawns /bin/bash and bridges IO. + +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + State, + }, + response::IntoResponse, +}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; +use tokio::process::Command; + +use crate::AppState; + +/// GET /api/terminal — upgrades to WebSocket, spawns a shell. +pub async fn terminal_ws( + ws: WebSocketUpgrade, + State(state): State, +) -> impl IntoResponse { + let project_path = state.config.project_path.clone(); + ws.on_upgrade(move |socket| handle_socket(socket, project_path)) +} + +async fn handle_socket(socket: WebSocket, project_path: String) { + use futures_util::{SinkExt, StreamExt}; + + let mut child = match Command::new("/bin/bash") + .current_dir(&project_path) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + let (mut sink, _) = socket.split(); + let _ = sink.send(Message::Text(format!("Failed to spawn shell: {e}\r\n"))).await; + return; + } + }; + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + + let (mut ws_sink, mut ws_stream) = socket.split(); + + // stdout → WS + let stdout_task = tokio::spawn(async move { + let mut reader = tokio::io::BufReader::new(stdout); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, + Ok(_) => { + if ws_sink.send(Message::Text(line.clone())).await.is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + // stderr → WS (interleaved into same stream) + let stderr_task = tokio::spawn(async move { + let mut reader = tokio::io::BufReader::new(stderr); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, + Ok(_) => { + // We can't send to ws_sink here (it was moved), so just drain + // In practice stderr goes to stdout via /bin/bash behavior + } + Err(_) => break, + } + } + }); + + // WS → stdin + while let Some(Ok(msg)) = ws_stream.next().await { + match msg { + Message::Text(text) => { + if stdin.write_all(text.as_bytes()).await.is_err() { + break; + } + } + Message::Binary(bytes) => { + if stdin.write_all(&bytes).await.is_err() { + break; + } + } + Message::Close(_) => break, + _ => {} + } + } + + // Kill child process + let _ = child.kill().await; + stdout_task.abort(); + stderr_task.abort(); +} diff --git a/ide/crates/el-ide-server/src/api/themes.rs b/ide/crates/el-ide-server/src/api/themes.rs new file mode 100644 index 0000000..382984e --- /dev/null +++ b/ide/crates/el-ide-server/src/api/themes.rs @@ -0,0 +1,68 @@ +//! Themes API — list and switch IDE themes. + +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; + +type ApiResult = Result, (StatusCode, Json)>; + +fn api_err(code: StatusCode, msg: impl Into) -> (StatusCode, Json) { + (code, Json(serde_json::json!({ "error": msg.into() }))) +} + +#[derive(Debug, Serialize)] +pub struct ThemeInfo { + pub name: String, + pub label: String, + pub active: bool, +} + +#[derive(Debug, Deserialize)] +pub struct SetThemeRequest { + pub name: String, +} + +#[derive(Debug, Serialize)] +pub struct OkResponse { + pub ok: bool, + pub active: String, +} + +const AVAILABLE_THEMES: &[(&str, &str)] = &[ + ("dark", "Dark"), + ("light", "Light"), + ("neuron", "Neuron"), + ("high-contrast", "High Contrast"), +]; + +/// GET /api/themes — list available themes +pub async fn list_themes(State(state): State) -> ApiResult> { + let active = state.active_theme.read().await.clone(); + let themes = AVAILABLE_THEMES + .iter() + .map(|(name, label)| ThemeInfo { + name: name.to_string(), + label: label.to_string(), + active: *name == active, + }) + .collect(); + Ok(Json(themes)) +} + +/// POST /api/themes/active — body: { name } +pub async fn set_theme( + State(state): State, + Json(req): Json, +) -> ApiResult { + let valid = AVAILABLE_THEMES.iter().any(|(n, _)| *n == req.name); + if !valid { + return Err(api_err(StatusCode::BAD_REQUEST, format!("unknown theme: {}", req.name))); + } + *state.active_theme.write().await = req.name.clone(); + Ok(Json(OkResponse { ok: true, active: req.name })) +} diff --git a/ide/crates/el-ide-server/src/embed.rs b/ide/crates/el-ide-server/src/embed.rs index 6f45a53..75bf9f5 100644 --- a/ide/crates/el-ide-server/src/embed.rs +++ b/ide/crates/el-ide-server/src/embed.rs @@ -1,7 +1,6 @@ //! Embedded static assets via rust-embed. use axum::{ - extract::Path, http::{header, StatusCode}, response::{IntoResponse, Response}, }; @@ -16,9 +15,9 @@ pub async fn serve_index() -> impl IntoResponse { serve_file("index.html") } -/// Serve any embedded asset by path. -pub async fn serve_asset(Path(path): Path) -> impl IntoResponse { - serve_file(&path) +/// Fallback handler — serves index.html for unknown paths (SPA routing). +pub async fn fallback() -> impl IntoResponse { + serve_file("index.html") } fn serve_file(path: &str) -> Response { diff --git a/ide/crates/el-ide-server/src/main.rs b/ide/crates/el-ide-server/src/main.rs index fb04740..21df0b4 100644 --- a/ide/crates/el-ide-server/src/main.rs +++ b/ide/crates/el-ide-server/src/main.rs @@ -115,6 +115,12 @@ pub fn build_router(state: AppState) -> Router { .route("/api/terminal", any(api::terminal::terminal_ws)) // Reasoning (proxy to engram-server) .route("/api/reason", post(api::reason::reason)) + // Settings + .route("/api/settings", + get(api::settings::get_settings) + .post(api::settings::save_settings) + .delete(api::settings::reset_settings) + ) .layer(cors) .with_state(state) // Fallback for SPA routing — must come after with_state/layer diff --git a/ide/crates/el-ide-server/src/tests.rs b/ide/crates/el-ide-server/src/tests.rs index 44f967f..6fc0a3f 100644 --- a/ide/crates/el-ide-server/src/tests.rs +++ b/ide/crates/el-ide-server/src/tests.rs @@ -1,7 +1,7 @@ //! Integration tests for el-ide-server API endpoints. use std::sync::Arc; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, RwLock}; use axum::{ body::Body, @@ -21,6 +21,7 @@ fn test_state(project_path: &str) -> AppState { engram_url: "http://localhost:8742".into(), }), plugins: Arc::new(Mutex::new(PluginHost::new())), + active_theme: Arc::new(RwLock::new("dark".into())), } } diff --git a/ide/crates/el-lsp/src/completion.rs b/ide/crates/el-lsp/src/completion.rs index 82f8f00..7d7ccef 100644 --- a/ide/crates/el-lsp/src/completion.rs +++ b/ide/crates/el-lsp/src/completion.rs @@ -29,7 +29,71 @@ pub enum CompletionKind { const KEYWORDS: &[&str] = &[ "let", "fn", "type", "enum", "match", "return", "activate", "where", - "sealed", "if", "else", "for", "in", "true", "false", + "sealed", "if", "else", "for", "in", "while", "true", "false", + "test", "seed", "assert", "target", "protocol", "impl", + "import", "from", "as", "with", "retry", "times", "fallback", + "reason", "parallel", "trace", "requires", "deploy", "to", "via", +]; + +// ── Builtin functions ───────────────────────────────────────────────────────── + +const BUILTIN_FUNCTIONS: &[(&str, &str)] = &[ + ("println", "fn(value: String) -> Void"), + ("print", "fn(value: String) -> Void"), + ("int_to_str", "fn(n: Int) -> String"), + ("str_to_int", "fn(s: String) -> Int"), + ("string_len", "fn(s: String) -> Int"), + ("str_slice", "fn(s: String, start: Int, end: Int) -> String"), + ("str_concat", "fn(a: String, b: String) -> String"), + ("str_contains", "fn(s: String, sub: String) -> Bool"), + ("str_starts_with", "fn(s: String, prefix: String) -> Bool"), + ("str_ends_with", "fn(s: String, suffix: String) -> Bool"), + ("str_to_upper", "fn(s: String) -> String"), + ("str_to_lower", "fn(s: String) -> String"), + ("str_split", "fn(s: String, sep: String) -> [String]"), + ("str_trim", "fn(s: String) -> String"), + ("str_replace", "fn(s: String, from: String, to: String) -> String"), + ("str_index_of", "fn(s: String, sub: String) -> Int"), + ("float_to_str", "fn(f: Float) -> String"), + ("str_to_float", "fn(s: String) -> Float"), + ("array_push", "fn(arr: [T], val: T) -> [T]"), + ("array_len", "fn(arr: [T]) -> Int"), + ("array_get", "fn(arr: [T], i: Int) -> T"), + ("list_len", "fn(list: [T]) -> Int"), + ("list_get", "fn(list: [T], i: Int) -> T"), + ("list_map", "fn(list: [T], f: fn(T) -> U) -> [U]"), + ("list_filter", "fn(list: [T], f: fn(T) -> Bool) -> [T]"), + ("list_reduce", "fn(list: [T], init: U, f: fn(U, T) -> U) -> U"), + ("map_create", "fn() -> Map"), + ("map_set", "fn(m: Map, key: String, val: T) -> Map"), + ("map_get", "fn(m: Map, key: String) -> T"), + ("map_has", "fn(m: Map, key: String) -> Bool"), + ("math_abs", "fn(n: Float) -> Float"), + ("math_sqrt", "fn(n: Float) -> Float"), + ("math_floor", "fn(n: Float) -> Int"), + ("math_ceil", "fn(n: Float) -> Int"), + ("math_round", "fn(n: Float) -> Int"), + ("math_min", "fn(a: Float, b: Float) -> Float"), + ("math_max", "fn(a: Float, b: Float) -> Float"), + ("math_pow", "fn(base: Float, exp: Float) -> Float"), + ("now_millis", "fn() -> Int"), + ("time_now_utc", "fn() -> String"), + ("time_to_parts", "fn(ts: String) -> Map"), + ("time_format", "fn(ts: String, fmt: String) -> String"), + ("llm_call", "fn(prompt: String) -> String"), + ("llm_parallel", "fn(prompts: [String]) -> [String]"), + ("random_int", "fn(min: Int, max: Int) -> Int"), + ("random_float", "fn() -> Float"), + ("parse_json", "fn(s: String) -> Map"), + ("to_json", "fn(v: T) -> String"), + ("http_get", "fn(url: String) -> String"), + ("http_post", "fn(url: String, body: String) -> String"), + ("read_file", "fn(path: String) -> String"), + ("write_file", "fn(path: String, content: String) -> Void"), + ("env_get", "fn(key: String) -> String"), + ("sleep_ms", "fn(ms: Int) -> Void"), + ("uuid_new", "fn() -> String"), + ("hash_sha256", "fn(s: String) -> String"), ]; const BUILTIN_TYPES: &[(&str, &str)] = &[ @@ -107,6 +171,12 @@ pub fn completions_at(env: &TypeEnv, source: &str, cursor_pos: usize) -> Vec { ("primitive type".into(), None, 0.05) } + TypeDef::Protocol { methods, .. } => { + let m_list = methods.iter().map(|m| m.name.clone()).collect::>().join(", "); + (format!("protocol {{ {m_list} }}"), + Some(format!("Protocol methods: {m_list}")), + 0.15) + } }; results.push(Completion { label: type_name.clone(), @@ -118,7 +188,20 @@ pub fn completions_at(env: &TypeEnv, source: &str, cursor_pos: usize) -> Vec Vec Option { "if" => "Conditional: `if cond { then } else { else }`", "else" => "Else branch of an if expression.", "for" => "For loop: `for item in collection { body }`", + "while" => "While loop: `while cond { body }`", "in" => "Used in `for item in collection`", "true" | "false" => "Boolean literal", + "protocol" => "Define a protocol (trait): `protocol Name { fn method(self) -> Ret; }`", + "impl" => "Implement a protocol: `impl Protocol for Type { fn method(self) -> Ret { ... } }`", + "import" => "Import from a module: `import { Name } from \"module\"`", + "from" => "Used in import: `import { Name } from \"module\"`", + "as" => "Alias in import: `import { Name as Alias } from \"module\"`", + "with" => "With clause for retry/fallback: `with retry times 3`", + "retry" => "Retry policy: `retry times N`", + "times" => "Used in retry: `retry times N`", + "fallback" => "Fallback value on failure: `fallback { default_expr }`", + "reason" => "Reason clause: provides context to Engram reasoning engine.", + "parallel" => "Parallel execution: `parallel { task1; task2 }`", + "trace" => "Emit a trace event: `trace \"message\"`", + "requires" => "Dependency declaration: `requires Module`", + "deploy" => "Deploy declaration: `deploy service to target via method`", + "to" => "Used in deploy: `deploy X to Y`", + "via" => "Used in deploy: `deploy X via method`", + "test" => "Test declaration: `test \"name\" { assertions }`", + "seed" => "Seed data block: `seed { ... }`", + "assert" => "Assertion: `assert condition, \"message\"`", + "target" => "Target annotation: `target { ... }`", _ => return None, }; Some(doc.to_string()) diff --git a/ide/crates/el-lsp/src/diagnostic.rs b/ide/crates/el-lsp/src/diagnostic.rs index f605ca7..29bb5b0 100644 --- a/ide/crates/el-lsp/src/diagnostic.rs +++ b/ide/crates/el-lsp/src/diagnostic.rs @@ -31,7 +31,12 @@ pub fn check(source: &str) -> Vec { let tokens = match el_lexer::tokenize(source) { Ok(t) => t, Err(e) => { - out.push(Diagnostic::error(format!("Lex error: {e}"))); + out.push(Diagnostic { + message: format!("Lex error: {}", e.kind), + severity: "error".into(), + line: Some(e.span.line), + col: Some(e.span.col), + }); return out; } }; @@ -39,7 +44,12 @@ pub fn check(source: &str) -> Vec { let program = match el_parser::parse(tokens, source.to_string()) { Ok(p) => p, Err(e) => { - out.push(Diagnostic::error(format!("Parse error: {e}"))); + out.push(Diagnostic { + message: format!("Parse error: {}", e.kind), + severity: "error".into(), + line: Some(e.span.line), + col: Some(e.span.col), + }); return out; } }; diff --git a/ide/crates/el-lsp/src/hover.rs b/ide/crates/el-lsp/src/hover.rs index 0ddcac1..3b88453 100644 --- a/ide/crates/el-lsp/src/hover.rs +++ b/ide/crates/el-lsp/src/hover.rs @@ -127,5 +127,13 @@ fn format_typedef_doc(name: &str, def: &TypeDef) -> String { format!("enum {name} {{\n{variants_str}\n}}") } TypeDef::Primitive(t) => format!("primitive type {name} = {t}"), + TypeDef::Protocol { methods, .. } => { + let methods_str = methods + .iter() + .map(|m| format!(" fn {}()", m.name)) + .collect::>() + .join("\n"); + format!("protocol {name} {{\n{methods_str}\n}}") + } } } diff --git a/ide/crates/el-lsp/src/type_graph.rs b/ide/crates/el-lsp/src/type_graph.rs index d6196d3..e88ae54 100644 --- a/ide/crates/el-lsp/src/type_graph.rs +++ b/ide/crates/el-lsp/src/type_graph.rs @@ -119,6 +119,18 @@ pub fn build(env: &TypeEnv) -> TypeGraph { fields: vec![], }); } + TypeDef::Protocol { methods, .. } => { + let method_strs: Vec = methods + .iter() + .map(|m| m.name.clone()) + .collect(); + nodes.push(TypeNode { + id: type_name.clone(), + name: type_name.clone(), + kind: "protocol".into(), + fields: method_strs, + }); + } } } diff --git a/ide/ide/index.html b/ide/ide/index.html index 3e0167a..e1ccd25 100644 --- a/ide/ide/index.html +++ b/ide/ide/index.html @@ -1015,11 +1015,14 @@ body { top: 0; right: 0; width: 60px; height: 100%; - pointer-events: none; + pointer-events: all; overflow: hidden; z-index: 10; - opacity: 0.5; + opacity: 0.6; + cursor: pointer; + transition: opacity 0.15s; } +#minimap-container:hover { opacity: 0.9; } #minimap-canvas { width: 100%; height: 100%; @@ -1034,6 +1037,17 @@ body { pointer-events: none; } +/* ── RESIZABLE PANELS ─────────────────────────────────────────────────────── */ +.panel-resize-handle { + width: 4px; + background: transparent; + cursor: ew-resize; + flex-shrink: 0; + transition: background 0.15s; + z-index: 20; +} +.panel-resize-handle:hover { background: var(--accent-dim); } + #graph-legend { padding: 8px 12px; border-top: 1px solid var(--border); @@ -1683,11 +1697,15 @@ body {
-
Files
+
+ Files + +
Loading...
+
@@ -1981,7 +1999,9 @@ import { keymap, drawSelection } from 'https://esm.sh/@codemirror/vie import { indentWithTab, history, historyKeymap, defaultKeymap } from 'https://esm.sh/@codemirror/commands@6'; import { StreamLanguage, foldGutter, codeFolding, foldKeymap } from 'https://esm.sh/@codemirror/language@6'; import { linter, lintGutter } from 'https://esm.sh/@codemirror/lint@6'; -import { EditorState, StateEffect, StateField, RangeSetBuilder, Transaction } from 'https://esm.sh/@codemirror/state@6'; +import { EditorState, EditorSelection, StateEffect, StateField, RangeSetBuilder, Transaction } from 'https://esm.sh/@codemirror/state@6'; +// Expose EditorSelection for selectNextOccurrence helper +window._cm6State = { EditorSelection }; import { Decoration, MatchDecorator, ViewPlugin } from 'https://esm.sh/@codemirror/view@6'; import { autocompletion, completionKeymap, snippetCompletion } from 'https://esm.sh/@codemirror/autocomplete@6'; import { searchKeymap, search, SearchQuery, setSearchQuery, openSearchPanel, closeSearchPanel } from 'https://esm.sh/@codemirror/search@6'; @@ -1999,9 +2019,13 @@ let vimExtension = null; // ═══════════════════════════════════════════════════════════════════════════ // Engram-lang StreamLanguage definition for CodeMirror 6 // ═══════════════════════════════════════════════════════════════════════════ -const KEYWORDS2 = new Set(['activate', 'sealed']); -const KEYWORDS = new Set(['let','fn','type','enum','match','return','where','if','else','for','in','true','false','protocol','impl']); -const BUILTINS = new Set(['Int','Float','String','Bool','Uuid','Void','List','Map','Option','Result']); +const KEYWORDS2 = new Set(['activate', 'sealed', 'parallel', 'deploy']); +const KEYWORDS = new Set([ + 'let','fn','type','enum','match','return','where','if','else','for','in','while', + 'true','false','protocol','impl','import','from','as','with','retry','times', + 'fallback','reason','trace','requires','to','via','test','seed','assert','target', +]); +const BUILTINS = new Set(['Int','Float','String','Bool','Uuid','Void','List','Map','Option','Result','Unit']); const engramLang = StreamLanguage.define({ name: 'engram', @@ -2023,6 +2047,8 @@ const engramLang = StreamLanguage.define({ if (KEYWORDS.has(word)) return 'keyword'; if (BUILTINS.has(word)) return 'builtin'; if (/^[A-Z]/.test(word)) return 'type'; + // Function call: identifier followed by ( + if (stream.peek() === '(') return 'function'; return 'variable'; } if (stream.match('->') || stream.match('=>') || stream.match('::') @@ -2060,6 +2086,9 @@ function engramTheme() { '.tok-operator': { color: 'var(--text2)' }, '.tok-punctuation': { color: 'var(--text3)' }, '.tok-variable': { color: 'var(--text)' }, + '.tok-function': { color: 'var(--yellow)' }, + '.tok-def': { color: 'var(--accent)', fontWeight: '600' }, + '.tok-atom': { color: 'var(--orange)' }, }, { dark: true }); } @@ -2076,6 +2105,17 @@ const ENGRAM_SNIPPETS = [ snippetCompletion('impl ${Protocol} for ${Type} {\n\t${body}\n}', { label: 'impl', detail: 'impl block', type: 'keyword' }), snippetCompletion('if ${cond} {\n\t${body}\n}', { label: 'if', detail: 'if expression', type: 'keyword' }), snippetCompletion('for ${item} in ${iter} {\n\t${body}\n}', { label: 'for', detail: 'for loop', type: 'keyword' }), + snippetCompletion('test "${description}" {\n\t${body}\n}', { label: 'test', detail: 'test block', type: 'keyword' }), + snippetCompletion('seed {\n\t${body}\n}', { label: 'seed', detail: 'seed block', type: 'keyword' }), + snippetCompletion('assert ${expr};', { label: 'assert', detail: 'assertion', type: 'keyword' }), + snippetCompletion('activate ${TypeName} where "${query}"', { label: 'activate', detail: 'activate query', type: 'keyword' }), + snippetCompletion('parallel {\n\t${task1},\n\t${task2},\n}', { label: 'parallel', detail: 'parallel block', type: 'keyword' }), + snippetCompletion('deploy ${service} to ${target} via ${method};', { label: 'deploy', detail: 'deploy statement', type: 'keyword' }), + snippetCompletion('import ${symbol} from "${module}";', { label: 'import', detail: 'import statement', type: 'keyword' }), + snippetCompletion('with ${resource} as ${name} {\n\t${body}\n}', { label: 'with', detail: 'with block', type: 'keyword' }), + snippetCompletion('retry ${times} times {\n\t${body}\n} fallback {\n\t${fallback}\n}', { label: 'retry', detail: 'retry/fallback', type: 'keyword' }), + snippetCompletion('reason "${goal}" {\n\t${context}\n}', { label: 'reason', detail: 'reasoning block', type: 'keyword' }), + snippetCompletion('trace "${label}" {\n\t${body}\n}', { label: 'trace', detail: 'trace block', type: 'keyword' }), ]; function engramSnippetSource(context) { @@ -2374,12 +2414,29 @@ const lspLinter = linter(async (view) => { if (!resp.ok) return []; const diags = await resp.json(); updateProblemsPanel(diags); - return diags.map(d => ({ - from: 0, - to: Math.min(source.length, 1), - severity: d.severity === 'error' ? 'error' : 'warning', - message: d.message, - })); + return diags.map(d => { + let from = 0; + let to = Math.min(source.length, 1); + // Use line/col if available for accurate underlines + if (d.line) { + try { + const lineInfo = view.state.doc.line(d.line); + const col = Math.max(0, (d.col || 1) - 1); + from = lineInfo.from + col; + // Underline to end of word or end of line + const lineText = lineInfo.text; + let endCol = col; + while (endCol < lineText.length && /\w/.test(lineText[endCol])) endCol++; + to = lineInfo.from + Math.max(endCol, col + 1); + } catch {} + } + return { + from, + to, + severity: d.severity === 'error' ? 'error' : 'warning', + message: d.message, + }; + }); } catch { return []; } }, { delay: 600 }); @@ -2490,6 +2547,32 @@ const editor = new EditorView({ openSearchPanel(editor); return true; } + if ((e.ctrlKey || e.metaKey) && e.key === 'g') { + e.preventDefault(); + goToLine(); + return true; + } + if ((e.ctrlKey || e.metaKey) && e.key === 'r') { + e.preventDefault(); + runBuildOrRun('run'); + return true; + } + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'K') { + // Delete current line + e.preventDefault(); + const sel = editor.state.selection.main; + const line = editor.state.doc.lineAt(sel.head); + const from = line.from; + const to = line.to < editor.state.doc.length ? line.to + 1 : line.to; + editor.dispatch({ changes: { from, to, insert: '' } }); + return true; + } + if ((e.ctrlKey || e.metaKey) && e.key === '/') { + // Toggle line comment + e.preventDefault(); + toggleLineComment(); + return true; + } return false; }, mousemove(e, view) { @@ -2828,6 +2911,15 @@ const IDE_COMMANDS = [ { label: 'Open Knowledge Graph', icon: '◈', action: () => { switchBottomTab('knowledge'); } }, { label: 'Neuron: Explain File', icon: '⟁', action: () => sendNeuronMessage('Explain this file: what does it do and how is it structured?') }, { label: 'Neuron: Suggest Activations', icon: '⟁', action: () => sendNeuronMessage('What activate queries would be useful for the types in this file?') }, + { label: 'Go To Line', icon: '→', action: () => goToLine() }, + { label: 'Toggle Word Wrap', icon: '↵', action: () => { wordWrapCheck.checked = !wordWrapCheck.checked; wordWrapCheck.dispatchEvent(new Event('change')); } }, + { label: 'Toggle Minimap', icon: '▦', action: () => { const mm = document.getElementById('minimap-container'); mm.style.display = mm.style.display === 'none' ? '' : 'none'; } }, + { label: 'Toggle File Tree', icon: '⊞', action: () => toggleFileTree() }, + { label: 'Toggle Bottom Panel', icon: '⬇', action: () => toggleBottomPanel() }, + { label: 'Increase Font Size', icon: 'A+', action: () => { const s = Math.min(24, parseInt(fontSizeSlider.value) + 1); fontSizeSlider.value = s; applySetting('fontSize', s); localStorage.setItem('el-ide-setting-fontSize', s); } }, + { label: 'Decrease Font Size', icon: 'A-', action: () => { const s = Math.max(10, parseInt(fontSizeSlider.value) - 1); fontSizeSlider.value = s; applySetting('fontSize', s); localStorage.setItem('el-ide-setting-fontSize', s); } }, + { label: 'Select Next Occurrence', icon: '▸▸', action: () => selectNextOccurrence() }, + { label: 'New File', icon: '+', action: () => { const name = prompt('New file name:'); if (name) createNewFile(name); } }, ]; function fuzzyScore(query, text) { @@ -2976,6 +3068,21 @@ document.addEventListener('keydown', e => { const next = e.shiftKey ? (idx - 1 + tabs.length) % tabs.length : (idx + 1) % tabs.length; switchTab(tabs[next].id); } + // Toggle file tree + if ((e.ctrlKey || e.metaKey) && e.key === 'b') { + e.preventDefault(); + toggleFileTree(); + } + // Toggle bottom panel + if ((e.ctrlKey || e.metaKey) && e.key === 'j') { + e.preventDefault(); + toggleBottomPanel(); + } + // Select next occurrence (Ctrl+D) + if ((e.ctrlKey || e.metaKey) && e.key === 'd' && !e.shiftKey) { + e.preventDefault(); + selectNextOccurrence(); + } }); // ═══════════════════════════════════════════════════════════════════════════ @@ -3626,9 +3733,39 @@ function _drawMinimap() { } // Hook minimap updates to editor changes -const origUpdateListener = editor.contentDOM; setInterval(updateMinimap, 500); // fallback polling +// Minimap click-to-jump +const minimapContainer = document.getElementById('minimap-container'); +minimapContainer.addEventListener('click', e => { + const rect = minimapContainer.getBoundingClientRect(); + const relY = e.clientY - rect.top; + const ratio = relY / rect.height; + const source = editor.state.doc.toString(); + const totalLines = Math.max(source.split('\n').length, 1); + const targetLine = Math.max(1, Math.min(Math.round(ratio * totalLines), totalLines)); + try { + const lineInfo = editor.state.doc.line(targetLine); + editor.dispatch({ selection: { anchor: lineInfo.from }, scrollIntoView: true }); + editor.focus(); + } catch {} +}); + +// Also allow dragging on the minimap to scroll +let minimapDragging = false; +minimapContainer.addEventListener('mousedown', () => { minimapDragging = true; }); +document.addEventListener('mousemove', e => { + if (!minimapDragging) return; + const rect = minimapContainer.getBoundingClientRect(); + const relY = Math.max(0, Math.min(e.clientY - rect.top, rect.height)); + const ratio = relY / rect.height; + const cmScroller = document.querySelector('.cm-scroller'); + if (cmScroller) { + cmScroller.scrollTop = ratio * (cmScroller.scrollHeight - cmScroller.clientHeight); + } +}); +document.addEventListener('mouseup', () => { minimapDragging = false; }); + // ═══════════════════════════════════════════════════════════════════════════ // Sticky scroll — show current function/type header at top of editor // ═══════════════════════════════════════════════════════════════════════════ @@ -3750,6 +3887,58 @@ async function findReferences(pos) { } catch {} } +// ═══════════════════════════════════════════════════════════════════════════ +// Go to line (Ctrl+G) +// ═══════════════════════════════════════════════════════════════════════════ +function goToLine() { + const input = prompt('Go to line:'); + if (!input) return; + const lineNum = parseInt(input.trim(), 10); + if (isNaN(lineNum) || lineNum < 1) return; + try { + const doc = editor.state.doc; + const clampedLine = Math.min(Math.max(1, lineNum), doc.lines); + const lineInfo = doc.line(clampedLine); + editor.dispatch({ + selection: { anchor: lineInfo.from }, + scrollIntoView: true, + }); + editor.focus(); + showToast(`Line ${clampedLine}`); + } catch {} +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Toggle line comment (Ctrl+/) +// ═══════════════════════════════════════════════════════════════════════════ +function toggleLineComment() { + const sel = editor.state.selection.main; + const doc = editor.state.doc; + const startLine = doc.lineAt(sel.from); + const endLine = doc.lineAt(sel.to); + + // Determine if all selected lines are commented + const lines = []; + for (let i = startLine.number; i <= endLine.number; i++) { + lines.push(doc.line(i)); + } + + const allCommented = lines.every(l => l.text.trimStart().startsWith('//')); + + const changes = lines.map(l => { + if (allCommented) { + // Remove comment + const ci = l.text.indexOf('//'); + return { from: l.from + ci, to: l.from + ci + 2, insert: '' }; + } else { + return { from: l.from, insert: '//' }; + } + }); + + editor.dispatch({ changes }); + editor.focus(); +} + // ═══════════════════════════════════════════════════════════════════════════ // Tab keyboard shortcuts — Cmd+1..9 to switch tabs, middle-click to close // ═══════════════════════════════════════════════════════════════════════════ @@ -4354,6 +4543,9 @@ function applyTheme(name) { body: JSON.stringify({ name }), }).catch(() => {}); + // Persist theme in settings + saveSettingsToApi({ theme: name }); + // Redraw graph since canvas colors change drawGraph(); } @@ -4393,6 +4585,9 @@ const fontSizeVal = document.getElementById('setting-font-size-val'); const tabSizeSelect = document.getElementById('setting-tab-size'); const wordWrapCheck = document.getElementById('setting-word-wrap'); +// Word wrap compartment for dynamic reconfiguration +let wordWrapEnabled = false; + function applySetting(key, value) { localStorage.setItem(`el-ide-setting-${key}`, value); if (key === 'fontSize') { @@ -4400,32 +4595,109 @@ function applySetting(key, value) { fontSizeVal.textContent = value + 'px'; } if (key === 'wordWrap') { - // word wrap would need editor extension swap; mark for future + wordWrapEnabled = value === '1' || value === true; + applyWordWrap(wordWrapEnabled); + } +} + +function applyWordWrap(enable) { + // Toggle word wrap via CSS on the CM content + const cmContent = document.querySelector('.cm-content'); + const cmScroller = document.querySelector('.cm-scroller'); + if (cmContent) { + if (enable) { + cmContent.style.whiteSpace = 'pre-wrap'; + cmContent.style.wordBreak = 'break-all'; + } else { + cmContent.style.whiteSpace = 'pre'; + cmContent.style.wordBreak = ''; + } + } + if (cmScroller) { + cmScroller.style.overflowX = enable ? 'hidden' : 'auto'; } } fontSizeSlider.addEventListener('input', () => { applySetting('fontSize', fontSizeSlider.value); + saveSettingsToApi({ fontSize: parseInt(fontSizeSlider.value, 10) }); }); tabSizeSelect.addEventListener('change', () => { localStorage.setItem('el-ide-setting-tabSize', tabSizeSelect.value); + saveSettingsToApi({ tabSize: parseInt(tabSizeSelect.value, 10) }); }); wordWrapCheck.addEventListener('change', () => { - localStorage.setItem('el-ide-setting-wordWrap', wordWrapCheck.checked ? '1' : '0'); + applySetting('wordWrap', wordWrapCheck.checked ? '1' : '0'); + saveSettingsToApi({ wordWrap: wordWrapCheck.checked }); }); document.getElementById('setting-vim-mode').addEventListener('change', e => { setVimMode(e.target.checked); + saveSettingsToApi({ vimMode: e.target.checked }); }); document.getElementById('setting-format-on-save').addEventListener('change', e => { formatOnSave = e.target.checked; localStorage.setItem('el-ide-setting-formatOnSave', formatOnSave ? '1' : '0'); + saveSettingsToApi({ formatOnSave: e.target.checked }); }); +function applySettingsObject(s) { + if (s.fontSize) { + fontSizeSlider.value = s.fontSize; + applySetting('fontSize', s.fontSize); + localStorage.setItem('el-ide-setting-fontSize', s.fontSize); + } + if (s.tabSize) { + tabSizeSelect.value = s.tabSize; + localStorage.setItem('el-ide-setting-tabSize', s.tabSize); + } + if (s.wordWrap !== undefined) { + wordWrapCheck.checked = !!s.wordWrap; + applySetting('wordWrap', s.wordWrap ? '1' : '0'); + localStorage.setItem('el-ide-setting-wordWrap', s.wordWrap ? '1' : '0'); + } + if (s.vimMode !== undefined && s.vimMode) { + document.getElementById('setting-vim-mode').checked = true; + setVimMode(true); + } + if (s.formatOnSave !== undefined && s.formatOnSave) { + document.getElementById('setting-format-on-save').checked = true; + formatOnSave = true; + } + if (s.theme) { + applyTheme(s.theme); + } +} + +async function loadSettingsFromApi() { + try { + const resp = await fetch('/api/settings'); + if (!resp.ok) return; + const s = await resp.json(); + applySettingsObject(s); + } catch { /* fall through to localStorage */ } +} + +// Debounced save to API +let _settingsSaveTimer = null; +function saveSettingsToApi(patch) { + clearTimeout(_settingsSaveTimer); + _settingsSaveTimer = setTimeout(async () => { + try { + await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: patch }), + }); + } catch { /* silently ignore */ } + }, 500); +} + function restoreSettings() { + // Try localStorage as immediate fallback (before API call resolves) const fs = localStorage.getItem('el-ide-setting-fontSize'); if (fs) { fontSizeSlider.value = fs; @@ -4452,6 +4724,23 @@ function restoreSettings() { panel.style.height = bh + 'px'; panel.style.minHeight = bh + 'px'; } + // Restore file tree width + const tw = localStorage.getItem('el-ide-tree-w'); + if (tw) { + const tree = document.getElementById('file-tree'); + tree.style.width = tw + 'px'; + tree.style.minWidth = '100px'; + } + // Restore type-graph width + const gw = localStorage.getItem('el-ide-graph-w'); + if (gw) { + const tg = document.getElementById('type-graph'); + if (tg) { + tg.style.width = gw + 'px'; + tg.style.minWidth = '120px'; + tg.style.flex = 'none'; + } + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -4672,6 +4961,203 @@ async function formatCurrentFile() { }); })(); +// ═══════════════════════════════════════════════════════════════════════════ +// File tree resize + collapse +// ═══════════════════════════════════════════════════════════════════════════ +let fileTreeCollapsed = false; + +function toggleFileTree() { + const tree = document.getElementById('file-tree'); + const handle = document.getElementById('file-tree-resize'); + const btn = document.getElementById('btn-collapse-tree'); + fileTreeCollapsed = !fileTreeCollapsed; + if (fileTreeCollapsed) { + tree.style.width = '0'; + tree.style.minWidth = '0'; + tree.style.overflow = 'hidden'; + handle.style.display = 'none'; + if (btn) btn.textContent = '›'; + } else { + const saved = localStorage.getItem('el-ide-tree-w') || '220'; + tree.style.width = saved + 'px'; + tree.style.minWidth = '100px'; + tree.style.overflow = ''; + handle.style.display = ''; + if (btn) btn.textContent = '‹'; + } +} + +document.getElementById('btn-collapse-tree').addEventListener('click', () => toggleFileTree()); + +(function initFileTreeResize() { + const handle = document.getElementById('file-tree-resize'); + const tree = document.getElementById('file-tree'); + let dragging = false; + let startX = 0; + let startW = 0; + + handle.addEventListener('mousedown', e => { + if (fileTreeCollapsed) return; + dragging = true; + startX = e.clientX; + startW = tree.offsetWidth; + document.body.style.cursor = 'ew-resize'; + document.body.style.userSelect = 'none'; + e.preventDefault(); + }); + + document.addEventListener('mousemove', e => { + if (!dragging) return; + const newW = Math.max(100, Math.min(500, startW + (e.clientX - startX))); + tree.style.width = newW + 'px'; + tree.style.minWidth = newW + 'px'; + }); + + document.addEventListener('mouseup', () => { + if (!dragging) return; + dragging = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + localStorage.setItem('el-ide-tree-w', tree.offsetWidth); + }); +})(); + +// ═══════════════════════════════════════════════════════════════════════════ +// Type-graph right-panel resize +// ═══════════════════════════════════════════════════════════════════════════ +(function initTypeGraphResize() { + // Insert a resize handle between editor-col and type-graph at runtime + const typeGraph = document.getElementById('type-graph'); + const mainRow = document.getElementById('main-row'); + if (!typeGraph || !mainRow) return; + + // Create handle element between editor-col and type-graph + const handle = document.createElement('div'); + handle.className = 'panel-resize-handle'; + handle.id = 'type-graph-resize'; + handle.style.cursor = 'ew-resize'; + mainRow.insertBefore(handle, typeGraph); + + let dragging = false; + let startX = 0; + let startW = 0; + + handle.addEventListener('mousedown', e => { + dragging = true; + startX = e.clientX; + startW = typeGraph.offsetWidth; + document.body.style.cursor = 'ew-resize'; + document.body.style.userSelect = 'none'; + e.preventDefault(); + }); + + document.addEventListener('mousemove', e => { + if (!dragging) return; + // Moving handle left increases type-graph width (handle is on the left of type-graph) + const newW = Math.max(120, Math.min(600, startW + (startX - e.clientX))); + typeGraph.style.width = newW + 'px'; + typeGraph.style.minWidth = newW + 'px'; + typeGraph.style.flex = 'none'; + }); + + document.addEventListener('mouseup', () => { + if (!dragging) return; + dragging = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + localStorage.setItem('el-ide-graph-w', typeGraph.offsetWidth); + }); +})(); + +// ═══════════════════════════════════════════════════════════════════════════ +// Toggle bottom panel +// ═══════════════════════════════════════════════════════════════════════════ +let bottomPanelVisible = true; + +function toggleBottomPanel() { + const panel = document.getElementById('bottom-panel'); + const handle = document.getElementById('bottom-resize-handle'); + bottomPanelVisible = !bottomPanelVisible; + if (bottomPanelVisible) { + const saved = localStorage.getItem('el-ide-bottom-h') || '180'; + panel.style.height = saved + 'px'; + panel.style.minHeight = saved + 'px'; + panel.style.display = ''; + if (handle) handle.style.display = ''; + } else { + localStorage.setItem('el-ide-bottom-h', panel.offsetHeight); + panel.style.display = 'none'; + if (handle) handle.style.display = 'none'; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Select next occurrence (Ctrl+D — add selection of next match) +// ═══════════════════════════════════════════════════════════════════════════ +function selectNextOccurrence() { + if (!editor) return; + const state = editor.state; + const sel = state.selection; + const mainSel = sel.main; + + let word; + if (mainSel.empty) { + // No selection — select word under cursor + const { from, to } = state.wordAt(mainSel.head) || { from: mainSel.head, to: mainSel.head }; + word = state.doc.sliceString(from, to); + if (!word) return; + editor.dispatch({ selection: { anchor: from, head: to } }); + return; + } + + // Text already selected — find and add next occurrence + word = state.doc.sliceString(mainSel.from, mainSel.to); + if (!word) return; + + const docText = state.doc.toString(); + const searchFrom = mainSel.to; + let idx = docText.indexOf(word, searchFrom); + if (idx === -1) idx = docText.indexOf(word, 0); // wrap around + if (idx === -1 || idx === mainSel.from) return; + + const { EditorSelection } = window._cm6State || {}; + if (!EditorSelection) { + // Fallback: just move selection + editor.dispatch({ selection: { anchor: idx, head: idx + word.length }, scrollIntoView: true }); + return; + } + + const newSel = EditorSelection.create([ + ...sel.ranges, + EditorSelection.range(idx, idx + word.length), + ], sel.ranges.length); + + editor.dispatch({ selection: newSel, scrollIntoView: true }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Create new file +// ═══════════════════════════════════════════════════════════════════════════ +async function createNewFile(filename) { + if (!filename) return; + try { + const resp = await fetch('/api/file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filename, content: '' }), + }); + if (resp.ok) { + await loadFileTree(); + openTab(filename, filename.split('/').pop(), ''); + showToast(`Created ${filename}`); + } else { + showToast('Failed to create file'); + } + } catch (e) { + showToast('Error creating file: ' + e.message); + } +} + // ═══════════════════════════════════════════════════════════════════════════ // Git Status — fetch changed files and annotate the file tree // ═══════════════════════════════════════════════════════════════════════════ @@ -4754,6 +5240,9 @@ async function init() { resizeCanvas(); window.addEventListener('resize', () => { resizeCanvas(); drawGraph(); }); + // Load settings from server (may override localStorage values) + loadSettingsFromApi(); + await Promise.all([loadProjectConfig(), loadFileTree(), loadServerSnippets()]); loadGitStatus();