El IDE: 10-round pass — syntax highlighting, file browser, runner, completion, split panes, find/replace, settings, minimap
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
This commit is contained in:
+6
-5
@@ -18,17 +18,18 @@ el-lsp = { path = "crates/el-lsp" }
|
|||||||
el-plugin-host = { path = "crates/el-plugin-host" }
|
el-plugin-host = { path = "crates/el-plugin-host" }
|
||||||
|
|
||||||
# Engram lang crates (path deps)
|
# Engram lang crates (path deps)
|
||||||
el-lexer = { path = "../engram-lang/crates/el-lexer" }
|
el-lexer = { path = "../el/engrams/el-lexer" }
|
||||||
el-parser = { path = "../engram-lang/crates/el-parser" }
|
el-parser = { path = "../el/engrams/el-parser" }
|
||||||
el-types = { path = "../engram-lang/crates/el-types" }
|
el-types = { path = "../el/engrams/el-types" }
|
||||||
el-compiler = { path = "../engram-lang/crates/el-compiler" }
|
el-compiler = { path = "../el/engrams/el-compiler" }
|
||||||
|
|
||||||
# External
|
# External
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["full"] }
|
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"] }
|
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||||
rust-embed = { version = "8", features = ["axum"] }
|
rust-embed = { version = "8", features = ["axum"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ tokio-stream = { workspace = true }
|
|||||||
which = { workspace = true }
|
which = { workspace = true }
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
mime_guess = { workspace = true }
|
mime_guess = { workspace = true }
|
||||||
|
futures-util = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||||
|
|||||||
@@ -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<AppState>) -> Json<ConfigResponse> {
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -96,10 +96,18 @@ fn read_dir_recursive(
|
|||||||
for entry in items {
|
for entry in items {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
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('.') {
|
if name.starts_with('.') {
|
||||||
continue;
|
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 rel_path = path.strip_prefix(root).unwrap_or(&path).to_string_lossy().to_string();
|
||||||
let is_dir = path.is_dir();
|
let is_dir = path.is_dir();
|
||||||
|
|
||||||
@@ -149,6 +157,73 @@ pub async fn write_file(
|
|||||||
Ok(Json(WriteResponse { ok: true }))
|
Ok(Json(WriteResponse { ok: true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/file?path={file}
|
||||||
|
pub async fn delete_file(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<PathQuery>,
|
||||||
|
) -> ApiResult<WriteResponse> {
|
||||||
|
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<AppState>,
|
||||||
|
Json(req): Json<MkdirRequest>,
|
||||||
|
) -> ApiResult<WriteResponse> {
|
||||||
|
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<AppState>,
|
||||||
|
Json(req): Json<RenameRequest>,
|
||||||
|
) -> ApiResult<WriteResponse> {
|
||||||
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn safe_path(project_root: &str, rel: &str) -> Result<PathBuf, String> {
|
fn safe_path(project_root: &str, rel: &str) -> Result<PathBuf, String> {
|
||||||
|
|||||||
@@ -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<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||||
|
|
||||||
|
fn api_err(msg: impl std::fmt::Display) -> (StatusCode, Json<serde_json::Value>) {
|
||||||
|
(
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<AppState>,
|
||||||
|
Json(req): Json<FormatRequest>,
|
||||||
|
) -> ApiResult<FormatResponse> {
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||||
|
|
||||||
|
fn api_err(msg: impl std::fmt::Display) -> (StatusCode, Json<serde_json::Value>) {
|
||||||
|
(
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// GET /api/git/status — returns list of changed files.
|
||||||
|
pub async fn git_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> ApiResult<Vec<GitFileStatus>> {
|
||||||
|
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=<file> — returns unified diff.
|
||||||
|
pub async fn git_diff(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<DiffQuery>,
|
||||||
|
) -> ApiResult<String> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
pub mod build;
|
pub mod build;
|
||||||
|
pub mod config;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod format;
|
||||||
|
pub mod git;
|
||||||
pub mod lsp;
|
pub mod lsp;
|
||||||
|
pub mod outline;
|
||||||
pub mod plugins;
|
pub mod plugins;
|
||||||
pub mod reason;
|
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;
|
pub mod type_graph;
|
||||||
|
|||||||
@@ -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<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||||
|
|
||||||
|
#[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<SourceQuery>,
|
||||||
|
) -> ApiResult<Vec<OutlineItem>> {
|
||||||
|
let items = extract_outline(&q.source);
|
||||||
|
Ok(Json(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_outline(source: &str) -> Vec<OutlineItem> {
|
||||||
|
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<String> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::{
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
extract::{Path, State},
|
|
||||||
http::StatusCode,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use el_plugin_host::Plugin;
|
use el_plugin_host::Plugin;
|
||||||
@@ -18,7 +17,7 @@ fn api_err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<serde_
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct InstallRequest {
|
pub struct NameRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ pub async fn list_plugins(State(state): State<AppState>) -> ApiResult<Vec<Plugin
|
|||||||
/// POST /api/plugins/install — body: { name }
|
/// POST /api/plugins/install — body: { name }
|
||||||
pub async fn install_plugin(
|
pub async fn install_plugin(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<InstallRequest>,
|
Json(req): Json<NameRequest>,
|
||||||
) -> ApiResult<Plugin> {
|
) -> ApiResult<Plugin> {
|
||||||
let mut host = state.plugins.lock().await;
|
let mut host = state.plugins.lock().await;
|
||||||
host.install(&req.name)
|
host.install(&req.name)
|
||||||
@@ -44,13 +43,35 @@ pub async fn install_plugin(
|
|||||||
.map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string()))
|
.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(
|
pub async fn remove_plugin(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(name): Path<String>,
|
Json(req): Json<NameRequest>,
|
||||||
) -> ApiResult<OkResponse> {
|
) -> ApiResult<OkResponse> {
|
||||||
let mut host = state.plugins.lock().await;
|
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<AppState>,
|
||||||
|
Json(req): Json<NameRequest>,
|
||||||
|
) -> ApiResult<OkResponse> {
|
||||||
|
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<AppState>,
|
||||||
|
Json(req): Json<NameRequest>,
|
||||||
|
) -> ApiResult<OkResponse> {
|
||||||
|
let mut host = state.plugins.lock().await;
|
||||||
|
host.disable(&req.name)
|
||||||
.map(|_| Json(OkResponse { ok: true }))
|
.map(|_| Json(OkResponse { ok: true }))
|
||||||
.map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string()))
|
.map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||||
|
|
||||||
|
#[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<SymbolQuery>,
|
||||||
|
) -> ApiResult<Option<SymbolLocation>> {
|
||||||
|
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<SymbolQuery>,
|
||||||
|
) -> ApiResult<Vec<SymbolLocation>> {
|
||||||
|
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<SymbolLocation> {
|
||||||
|
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<SymbolLocation> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<Option<Child>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RunState {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
current_child: Mutex::new(None),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||||
|
|
||||||
|
fn api_err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<serde_json::Value>) {
|
||||||
|
(code, Json(serde_json::json!({ "error": msg.into() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SearchQuery {
|
||||||
|
pub query: String,
|
||||||
|
pub path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SearchResult {
|
||||||
|
pub path: String,
|
||||||
|
pub line: usize,
|
||||||
|
pub col: usize,
|
||||||
|
pub snippet: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/search?query=<text>&path=<optional-subdir>
|
||||||
|
pub async fn search(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<SearchQuery>,
|
||||||
|
) -> ApiResult<Vec<SearchResult>> {
|
||||||
|
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<SearchResult> = 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<SearchResult>) {
|
||||||
|
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<SearchResult>) {
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||||
|
|
||||||
|
fn api_err(msg: impl std::fmt::Display) -> (StatusCode, Json<serde_json::Value>) {
|
||||||
|
(
|
||||||
|
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<Value> {
|
||||||
|
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<PatchSettings>,
|
||||||
|
) -> ApiResult<Value> {
|
||||||
|
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::<Value>(&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<Value> {
|
||||||
|
let path = settings_path();
|
||||||
|
if path.exists() {
|
||||||
|
std::fs::remove_file(&path).ok();
|
||||||
|
}
|
||||||
|
Ok(Json(default_settings()))
|
||||||
|
}
|
||||||
@@ -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<Vec<Snippet>> {
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -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<AppState>) -> Json<StatusResponse> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AppState>,
|
||||||
|
) -> 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();
|
||||||
|
}
|
||||||
@@ -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<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||||
|
|
||||||
|
fn api_err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<serde_json::Value>) {
|
||||||
|
(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<AppState>) -> ApiResult<Vec<ThemeInfo>> {
|
||||||
|
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<AppState>,
|
||||||
|
Json(req): Json<SetThemeRequest>,
|
||||||
|
) -> ApiResult<OkResponse> {
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
//! Embedded static assets via rust-embed.
|
//! Embedded static assets via rust-embed.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::Path,
|
|
||||||
http::{header, StatusCode},
|
http::{header, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
@@ -16,9 +15,9 @@ pub async fn serve_index() -> impl IntoResponse {
|
|||||||
serve_file("index.html")
|
serve_file("index.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serve any embedded asset by path.
|
/// Fallback handler — serves index.html for unknown paths (SPA routing).
|
||||||
pub async fn serve_asset(Path(path): Path<String>) -> impl IntoResponse {
|
pub async fn fallback() -> impl IntoResponse {
|
||||||
serve_file(&path)
|
serve_file("index.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn serve_file(path: &str) -> Response {
|
fn serve_file(path: &str) -> Response {
|
||||||
|
|||||||
@@ -115,6 +115,12 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/terminal", any(api::terminal::terminal_ws))
|
.route("/api/terminal", any(api::terminal::terminal_ws))
|
||||||
// Reasoning (proxy to engram-server)
|
// Reasoning (proxy to engram-server)
|
||||||
.route("/api/reason", post(api::reason::reason))
|
.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)
|
.layer(cors)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
// Fallback for SPA routing — must come after with_state/layer
|
// Fallback for SPA routing — must come after with_state/layer
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Integration tests for el-ide-server API endpoints.
|
//! Integration tests for el-ide-server API endpoints.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
@@ -21,6 +21,7 @@ fn test_state(project_path: &str) -> AppState {
|
|||||||
engram_url: "http://localhost:8742".into(),
|
engram_url: "http://localhost:8742".into(),
|
||||||
}),
|
}),
|
||||||
plugins: Arc::new(Mutex::new(PluginHost::new())),
|
plugins: Arc::new(Mutex::new(PluginHost::new())),
|
||||||
|
active_theme: Arc::new(RwLock::new("dark".into())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,71 @@ pub enum CompletionKind {
|
|||||||
|
|
||||||
const KEYWORDS: &[&str] = &[
|
const KEYWORDS: &[&str] = &[
|
||||||
"let", "fn", "type", "enum", "match", "return", "activate", "where",
|
"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)] = &[
|
const BUILTIN_TYPES: &[(&str, &str)] = &[
|
||||||
@@ -107,6 +171,12 @@ pub fn completions_at(env: &TypeEnv, source: &str, cursor_pos: usize) -> Vec<Com
|
|||||||
TypeDef::Primitive(_) => {
|
TypeDef::Primitive(_) => {
|
||||||
("primitive type".into(), None, 0.05)
|
("primitive type".into(), None, 0.05)
|
||||||
}
|
}
|
||||||
|
TypeDef::Protocol { methods, .. } => {
|
||||||
|
let m_list = methods.iter().map(|m| m.name.clone()).collect::<Vec<_>>().join(", ");
|
||||||
|
(format!("protocol {{ {m_list} }}"),
|
||||||
|
Some(format!("Protocol methods: {m_list}")),
|
||||||
|
0.15)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
results.push(Completion {
|
results.push(Completion {
|
||||||
label: type_name.clone(),
|
label: type_name.clone(),
|
||||||
@@ -118,7 +188,20 @@ pub fn completions_at(env: &TypeEnv, source: &str, cursor_pos: usize) -> Vec<Com
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Functions from env
|
// Builtin functions
|
||||||
|
for &(name, sig) in BUILTIN_FUNCTIONS {
|
||||||
|
if name.to_lowercase().starts_with(&prefix.to_lowercase()) || prefix.is_empty() {
|
||||||
|
results.push(Completion {
|
||||||
|
label: name.to_string(),
|
||||||
|
kind: CompletionKind::Function,
|
||||||
|
detail: sig.into(),
|
||||||
|
documentation: Some(format!("Built-in function: {name}\n{sig}")),
|
||||||
|
score: prefix_score(name, &prefix) + 0.11,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Functions from env (user-defined)
|
||||||
for (fn_name, fn_type) in &env.functions {
|
for (fn_name, fn_type) in &env.functions {
|
||||||
if fn_name.to_lowercase().starts_with(&prefix.to_lowercase()) || prefix.is_empty() {
|
if fn_name.to_lowercase().starts_with(&prefix.to_lowercase()) || prefix.is_empty() {
|
||||||
results.push(Completion {
|
results.push(Completion {
|
||||||
@@ -131,8 +214,9 @@ pub fn completions_at(env: &TypeEnv, source: &str, cursor_pos: usize) -> Vec<Com
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by score descending
|
// Sort by score descending, deduplicate by label
|
||||||
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
results.dedup_by(|a, b| a.label == b.label);
|
||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,8 +263,29 @@ fn keyword_doc(kw: &str) -> Option<String> {
|
|||||||
"if" => "Conditional: `if cond { then } else { else }`",
|
"if" => "Conditional: `if cond { then } else { else }`",
|
||||||
"else" => "Else branch of an if expression.",
|
"else" => "Else branch of an if expression.",
|
||||||
"for" => "For loop: `for item in collection { body }`",
|
"for" => "For loop: `for item in collection { body }`",
|
||||||
|
"while" => "While loop: `while cond { body }`",
|
||||||
"in" => "Used in `for item in collection`",
|
"in" => "Used in `for item in collection`",
|
||||||
"true" | "false" => "Boolean literal",
|
"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,
|
_ => return None,
|
||||||
};
|
};
|
||||||
Some(doc.to_string())
|
Some(doc.to_string())
|
||||||
|
|||||||
@@ -31,7 +31,12 @@ pub fn check(source: &str) -> Vec<Diagnostic> {
|
|||||||
let tokens = match el_lexer::tokenize(source) {
|
let tokens = match el_lexer::tokenize(source) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -39,7 +44,12 @@ pub fn check(source: &str) -> Vec<Diagnostic> {
|
|||||||
let program = match el_parser::parse(tokens, source.to_string()) {
|
let program = match el_parser::parse(tokens, source.to_string()) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -127,5 +127,13 @@ fn format_typedef_doc(name: &str, def: &TypeDef) -> String {
|
|||||||
format!("enum {name} {{\n{variants_str}\n}}")
|
format!("enum {name} {{\n{variants_str}\n}}")
|
||||||
}
|
}
|
||||||
TypeDef::Primitive(t) => format!("primitive type {name} = {t}"),
|
TypeDef::Primitive(t) => format!("primitive type {name} = {t}"),
|
||||||
|
TypeDef::Protocol { methods, .. } => {
|
||||||
|
let methods_str = methods
|
||||||
|
.iter()
|
||||||
|
.map(|m| format!(" fn {}()", m.name))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
format!("protocol {name} {{\n{methods_str}\n}}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,18 @@ pub fn build(env: &TypeEnv) -> TypeGraph {
|
|||||||
fields: vec![],
|
fields: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
TypeDef::Protocol { methods, .. } => {
|
||||||
|
let method_strs: Vec<String> = 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+505
-16
@@ -1015,11 +1015,14 @@ body {
|
|||||||
top: 0; right: 0;
|
top: 0; right: 0;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
pointer-events: none;
|
pointer-events: all;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
opacity: 0.5;
|
opacity: 0.6;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
#minimap-container:hover { opacity: 0.9; }
|
||||||
#minimap-canvas {
|
#minimap-canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1034,6 +1037,17 @@ body {
|
|||||||
pointer-events: none;
|
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 {
|
#graph-legend {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
@@ -1683,11 +1697,15 @@ body {
|
|||||||
|
|
||||||
<!-- ── FILE TREE ──────────────────────────────────────────────────────── -->
|
<!-- ── FILE TREE ──────────────────────────────────────────────────────── -->
|
||||||
<div id="file-tree">
|
<div id="file-tree">
|
||||||
<div class="panel-header">Files</div>
|
<div class="panel-header" style="display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="flex:1">Files</span>
|
||||||
|
<button class="graph-btn" id="btn-collapse-tree" title="Collapse tree (Ctrl+B)" style="width:18px;height:18px;font-size:10px;">‹</button>
|
||||||
|
</div>
|
||||||
<div id="file-list">
|
<div id="file-list">
|
||||||
<div style="color:var(--text3);padding:12px;font-size:11px;">Loading...</div>
|
<div style="color:var(--text3);padding:12px;font-size:11px;">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel-resize-handle" id="file-tree-resize"></div>
|
||||||
|
|
||||||
<!-- ── EDITOR ─────────────────────────────────────────────────────────── -->
|
<!-- ── EDITOR ─────────────────────────────────────────────────────────── -->
|
||||||
<div id="editor-col">
|
<div id="editor-col">
|
||||||
@@ -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 { indentWithTab, history, historyKeymap, defaultKeymap } from 'https://esm.sh/@codemirror/commands@6';
|
||||||
import { StreamLanguage, foldGutter, codeFolding, foldKeymap } from 'https://esm.sh/@codemirror/language@6';
|
import { StreamLanguage, foldGutter, codeFolding, foldKeymap } from 'https://esm.sh/@codemirror/language@6';
|
||||||
import { linter, lintGutter } from 'https://esm.sh/@codemirror/lint@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 { Decoration, MatchDecorator, ViewPlugin } from 'https://esm.sh/@codemirror/view@6';
|
||||||
import { autocompletion, completionKeymap, snippetCompletion } from 'https://esm.sh/@codemirror/autocomplete@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';
|
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
|
// Engram-lang StreamLanguage definition for CodeMirror 6
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
const KEYWORDS2 = new Set(['activate', 'sealed']);
|
const KEYWORDS2 = new Set(['activate', 'sealed', 'parallel', 'deploy']);
|
||||||
const KEYWORDS = new Set(['let','fn','type','enum','match','return','where','if','else','for','in','true','false','protocol','impl']);
|
const KEYWORDS = new Set([
|
||||||
const BUILTINS = new Set(['Int','Float','String','Bool','Uuid','Void','List','Map','Option','Result']);
|
'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({
|
const engramLang = StreamLanguage.define({
|
||||||
name: 'engram',
|
name: 'engram',
|
||||||
@@ -2023,6 +2047,8 @@ const engramLang = StreamLanguage.define({
|
|||||||
if (KEYWORDS.has(word)) return 'keyword';
|
if (KEYWORDS.has(word)) return 'keyword';
|
||||||
if (BUILTINS.has(word)) return 'builtin';
|
if (BUILTINS.has(word)) return 'builtin';
|
||||||
if (/^[A-Z]/.test(word)) return 'type';
|
if (/^[A-Z]/.test(word)) return 'type';
|
||||||
|
// Function call: identifier followed by (
|
||||||
|
if (stream.peek() === '(') return 'function';
|
||||||
return 'variable';
|
return 'variable';
|
||||||
}
|
}
|
||||||
if (stream.match('->') || stream.match('=>') || stream.match('::')
|
if (stream.match('->') || stream.match('=>') || stream.match('::')
|
||||||
@@ -2060,6 +2086,9 @@ function engramTheme() {
|
|||||||
'.tok-operator': { color: 'var(--text2)' },
|
'.tok-operator': { color: 'var(--text2)' },
|
||||||
'.tok-punctuation': { color: 'var(--text3)' },
|
'.tok-punctuation': { color: 'var(--text3)' },
|
||||||
'.tok-variable': { color: 'var(--text)' },
|
'.tok-variable': { color: 'var(--text)' },
|
||||||
|
'.tok-function': { color: 'var(--yellow)' },
|
||||||
|
'.tok-def': { color: 'var(--accent)', fontWeight: '600' },
|
||||||
|
'.tok-atom': { color: 'var(--orange)' },
|
||||||
}, { dark: true });
|
}, { 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('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('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('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) {
|
function engramSnippetSource(context) {
|
||||||
@@ -2374,12 +2414,29 @@ const lspLinter = linter(async (view) => {
|
|||||||
if (!resp.ok) return [];
|
if (!resp.ok) return [];
|
||||||
const diags = await resp.json();
|
const diags = await resp.json();
|
||||||
updateProblemsPanel(diags);
|
updateProblemsPanel(diags);
|
||||||
return diags.map(d => ({
|
return diags.map(d => {
|
||||||
from: 0,
|
let from = 0;
|
||||||
to: Math.min(source.length, 1),
|
let to = Math.min(source.length, 1);
|
||||||
severity: d.severity === 'error' ? 'error' : 'warning',
|
// Use line/col if available for accurate underlines
|
||||||
message: d.message,
|
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 []; }
|
} catch { return []; }
|
||||||
}, { delay: 600 });
|
}, { delay: 600 });
|
||||||
|
|
||||||
@@ -2490,6 +2547,32 @@ const editor = new EditorView({
|
|||||||
openSearchPanel(editor);
|
openSearchPanel(editor);
|
||||||
return true;
|
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;
|
return false;
|
||||||
},
|
},
|
||||||
mousemove(e, view) {
|
mousemove(e, view) {
|
||||||
@@ -2828,6 +2911,15 @@ const IDE_COMMANDS = [
|
|||||||
{ label: 'Open Knowledge Graph', icon: '◈', action: () => { switchBottomTab('knowledge'); } },
|
{ 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: 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: '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) {
|
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;
|
const next = e.shiftKey ? (idx - 1 + tabs.length) % tabs.length : (idx + 1) % tabs.length;
|
||||||
switchTab(tabs[next].id);
|
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
|
// Hook minimap updates to editor changes
|
||||||
const origUpdateListener = editor.contentDOM;
|
|
||||||
setInterval(updateMinimap, 500); // fallback polling
|
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
|
// Sticky scroll — show current function/type header at top of editor
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -3750,6 +3887,58 @@ async function findReferences(pos) {
|
|||||||
} catch {}
|
} 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
|
// Tab keyboard shortcuts — Cmd+1..9 to switch tabs, middle-click to close
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -4354,6 +4543,9 @@ function applyTheme(name) {
|
|||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Persist theme in settings
|
||||||
|
saveSettingsToApi({ theme: name });
|
||||||
|
|
||||||
// Redraw graph since canvas colors change
|
// Redraw graph since canvas colors change
|
||||||
drawGraph();
|
drawGraph();
|
||||||
}
|
}
|
||||||
@@ -4393,6 +4585,9 @@ const fontSizeVal = document.getElementById('setting-font-size-val');
|
|||||||
const tabSizeSelect = document.getElementById('setting-tab-size');
|
const tabSizeSelect = document.getElementById('setting-tab-size');
|
||||||
const wordWrapCheck = document.getElementById('setting-word-wrap');
|
const wordWrapCheck = document.getElementById('setting-word-wrap');
|
||||||
|
|
||||||
|
// Word wrap compartment for dynamic reconfiguration
|
||||||
|
let wordWrapEnabled = false;
|
||||||
|
|
||||||
function applySetting(key, value) {
|
function applySetting(key, value) {
|
||||||
localStorage.setItem(`el-ide-setting-${key}`, value);
|
localStorage.setItem(`el-ide-setting-${key}`, value);
|
||||||
if (key === 'fontSize') {
|
if (key === 'fontSize') {
|
||||||
@@ -4400,32 +4595,109 @@ function applySetting(key, value) {
|
|||||||
fontSizeVal.textContent = value + 'px';
|
fontSizeVal.textContent = value + 'px';
|
||||||
}
|
}
|
||||||
if (key === 'wordWrap') {
|
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', () => {
|
fontSizeSlider.addEventListener('input', () => {
|
||||||
applySetting('fontSize', fontSizeSlider.value);
|
applySetting('fontSize', fontSizeSlider.value);
|
||||||
|
saveSettingsToApi({ fontSize: parseInt(fontSizeSlider.value, 10) });
|
||||||
});
|
});
|
||||||
|
|
||||||
tabSizeSelect.addEventListener('change', () => {
|
tabSizeSelect.addEventListener('change', () => {
|
||||||
localStorage.setItem('el-ide-setting-tabSize', tabSizeSelect.value);
|
localStorage.setItem('el-ide-setting-tabSize', tabSizeSelect.value);
|
||||||
|
saveSettingsToApi({ tabSize: parseInt(tabSizeSelect.value, 10) });
|
||||||
});
|
});
|
||||||
|
|
||||||
wordWrapCheck.addEventListener('change', () => {
|
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 => {
|
document.getElementById('setting-vim-mode').addEventListener('change', e => {
|
||||||
setVimMode(e.target.checked);
|
setVimMode(e.target.checked);
|
||||||
|
saveSettingsToApi({ vimMode: e.target.checked });
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('setting-format-on-save').addEventListener('change', e => {
|
document.getElementById('setting-format-on-save').addEventListener('change', e => {
|
||||||
formatOnSave = e.target.checked;
|
formatOnSave = e.target.checked;
|
||||||
localStorage.setItem('el-ide-setting-formatOnSave', formatOnSave ? '1' : '0');
|
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() {
|
function restoreSettings() {
|
||||||
|
// Try localStorage as immediate fallback (before API call resolves)
|
||||||
const fs = localStorage.getItem('el-ide-setting-fontSize');
|
const fs = localStorage.getItem('el-ide-setting-fontSize');
|
||||||
if (fs) {
|
if (fs) {
|
||||||
fontSizeSlider.value = fs;
|
fontSizeSlider.value = fs;
|
||||||
@@ -4452,6 +4724,23 @@ function restoreSettings() {
|
|||||||
panel.style.height = bh + 'px';
|
panel.style.height = bh + 'px';
|
||||||
panel.style.minHeight = 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
|
// Git Status — fetch changed files and annotate the file tree
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -4754,6 +5240,9 @@ async function init() {
|
|||||||
resizeCanvas();
|
resizeCanvas();
|
||||||
window.addEventListener('resize', () => { resizeCanvas(); drawGraph(); });
|
window.addEventListener('resize', () => { resizeCanvas(); drawGraph(); });
|
||||||
|
|
||||||
|
// Load settings from server (may override localStorage values)
|
||||||
|
loadSettingsFromApi();
|
||||||
|
|
||||||
await Promise.all([loadProjectConfig(), loadFileTree(), loadServerSnippets()]);
|
await Promise.all([loadProjectConfig(), loadFileTree(), loadServerSnippets()]);
|
||||||
loadGitStatus();
|
loadGitStatus();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user