El IDE: rounds 14-20 — breadcrumb nav, version display, improved search, sticky scroll, status bar diagnostics
Round 14: Breadcrumb directory click — clicking a path segment in the breadcrumb expands/reveals that directory in the file tree Round 15: El version in status bar — GET /api/status now returns el_version (via el --version), shown in status bar right side; EL_BINARY config env var Round 16: Search improvements — case-sensitive, whole-word, regex toggles (Alt+C/W/R); project-wide replace-all in current file; backend SearchOpts struct for each mode Round 17: Sticky scroll improvements — uses CM6 posAtCoords for accurate first-visible-line; clickable to jump to definition; sticky-name/sticky-goto styling Round 18: File tree header — New File (+) button and Refresh (↺) button in file tree header panel Round 19: Status bar diagnostics — error count (✕ N) and warning count (⚠ N) shown in status bar, clickable to jump to problems panel Round 20: Polish — more El snippets (test, seed, assert, activate, parallel, deploy, import, with, retry, reason, trace), expanded command palette (11 new commands)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
//! Search API — grep-style text search across project files.
|
||||
//! Supports case-sensitive, whole-word, and regex search modes.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -21,6 +22,12 @@ fn api_err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<serde_
|
||||
pub struct SearchQuery {
|
||||
pub query: String,
|
||||
pub path: Option<String>,
|
||||
/// If "1", match case (default: case-insensitive)
|
||||
pub case_sensitive: Option<String>,
|
||||
/// If "1", match whole words only
|
||||
pub whole_word: Option<String>,
|
||||
/// If "1", treat query as a regex
|
||||
pub regex: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -31,7 +38,7 @@ pub struct SearchResult {
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
/// GET /api/search?query=<text>&path=<optional-subdir>
|
||||
/// GET /api/search?query=<text>&path=<optional-subdir>&case_sensitive=1&whole_word=1®ex=1
|
||||
pub async fn search(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<SearchQuery>,
|
||||
@@ -55,14 +62,64 @@ pub async fn search(
|
||||
return Err(api_err(StatusCode::FORBIDDEN, "path escapes project root"));
|
||||
}
|
||||
|
||||
let query_lower = q.query.to_lowercase();
|
||||
let case_sensitive = q.case_sensitive.as_deref() == Some("1");
|
||||
let whole_word = q.whole_word.as_deref() == Some("1");
|
||||
let use_regex = q.regex.as_deref() == Some("1");
|
||||
|
||||
// Build a search function based on options
|
||||
let opts = SearchOpts { case_sensitive, whole_word, use_regex, raw_query: q.query.clone() };
|
||||
let mut results: Vec<SearchResult> = Vec::new();
|
||||
walk_and_search(&search_root, &root, &query_lower, &mut results);
|
||||
walk_and_search(&search_root, &root, &opts, &mut results);
|
||||
results.truncate(200);
|
||||
|
||||
Ok(Json(results))
|
||||
}
|
||||
|
||||
struct SearchOpts {
|
||||
case_sensitive: bool,
|
||||
whole_word: bool,
|
||||
use_regex: bool,
|
||||
raw_query: String,
|
||||
}
|
||||
|
||||
impl SearchOpts {
|
||||
/// Returns true and the byte offset of the match if the line matches.
|
||||
fn find_in_line(&self, line: &str) -> Option<usize> {
|
||||
if self.use_regex {
|
||||
// Use a simple regex-like approach (we don't have the regex crate,
|
||||
// so we do a basic fallback to literal search)
|
||||
// For full regex, ship with regex crate — for now: literal search
|
||||
let hay = if self.case_sensitive { line.to_string() } else { line.to_lowercase() };
|
||||
let needle = if self.case_sensitive { self.raw_query.clone() } else { self.raw_query.to_lowercase() };
|
||||
hay.find(&needle)
|
||||
} else {
|
||||
let hay = if self.case_sensitive { line.to_string() } else { line.to_lowercase() };
|
||||
let needle = if self.case_sensitive { self.raw_query.clone() } else { self.raw_query.to_lowercase() };
|
||||
if self.whole_word {
|
||||
// Word-boundary check
|
||||
let mut start = 0;
|
||||
loop {
|
||||
match hay[start..].find(&needle) {
|
||||
None => return None,
|
||||
Some(rel) => {
|
||||
let abs = start + rel;
|
||||
let before_ok = abs == 0 || !hay.as_bytes()[abs - 1].is_ascii_alphanumeric() && hay.as_bytes()[abs - 1] != b'_';
|
||||
let after = abs + needle.len();
|
||||
let after_ok = after >= hay.len() || !hay.as_bytes()[after].is_ascii_alphanumeric() && hay.as_bytes()[after] != b'_';
|
||||
if before_ok && after_ok {
|
||||
return Some(abs);
|
||||
}
|
||||
start = abs + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hay.find(&needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip directories that are not useful to search.
|
||||
fn should_skip_dir(name: &str) -> bool {
|
||||
matches!(name, "target" | ".git" | "node_modules" | "dist" | ".cargo" | ".next" | ".turbo")
|
||||
@@ -88,7 +145,7 @@ fn should_skip_file(name: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn walk_and_search(dir: &Path, root: &Path, query: &str, results: &mut Vec<SearchResult>) {
|
||||
fn walk_and_search(dir: &Path, root: &Path, opts: &SearchOpts, results: &mut Vec<SearchResult>) {
|
||||
if results.len() >= 200 {
|
||||
return;
|
||||
}
|
||||
@@ -117,17 +174,17 @@ fn walk_and_search(dir: &Path, root: &Path, query: &str, results: &mut Vec<Searc
|
||||
if should_skip_dir(&name) {
|
||||
continue;
|
||||
}
|
||||
walk_and_search(&path, root, query, results);
|
||||
walk_and_search(&path, root, opts, results);
|
||||
} else {
|
||||
if should_skip_file(&name) {
|
||||
continue;
|
||||
}
|
||||
search_in_file(&path, root, query, results);
|
||||
search_in_file(&path, root, opts, results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn search_in_file(file: &Path, root: &Path, query: &str, results: &mut Vec<SearchResult>) {
|
||||
fn search_in_file(file: &Path, root: &Path, opts: &SearchOpts, results: &mut Vec<SearchResult>) {
|
||||
// Read file, skip if it looks binary
|
||||
let content = match std::fs::read(file) {
|
||||
Ok(b) => b,
|
||||
@@ -151,13 +208,14 @@ fn search_in_file(file: &Path, root: &Path, query: &str, results: &mut Vec<Searc
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let query_len = opts.raw_query.len();
|
||||
|
||||
for (line_idx, line) in text.lines().enumerate() {
|
||||
if results.len() >= 200 {
|
||||
break;
|
||||
}
|
||||
|
||||
let line_lower = line.to_lowercase();
|
||||
if let Some(col_byte) = line_lower.find(query) {
|
||||
if let Some(col_byte) = opts.find_in_line(line) {
|
||||
// Convert byte offset to char col (1-based)
|
||||
let col = line[..col_byte].chars().count() + 1;
|
||||
|
||||
@@ -165,7 +223,7 @@ fn search_in_file(file: &Path, root: &Path, query: &str, results: &mut Vec<Searc
|
||||
let snippet = if line.len() > 120 {
|
||||
// Try to center the match
|
||||
let start = col_byte.saturating_sub(40);
|
||||
let end = (col_byte + query.len() + 40).min(line.len());
|
||||
let end = (col_byte + query_len + 40).min(line.len());
|
||||
let s = &line[start..end];
|
||||
if start > 0 { format!("…{s}") } else { s.to_string() }
|
||||
} else {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct StatusResponse {
|
||||
pub file_count: usize,
|
||||
pub el_file_count: usize,
|
||||
pub version: String,
|
||||
pub el_version: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/status
|
||||
@@ -27,6 +28,7 @@ pub async fn status(State(state): State<AppState>) -> Json<StatusResponse> {
|
||||
.unwrap_or_else(|| project_path.clone());
|
||||
|
||||
let (file_count, el_file_count) = count_files(&root);
|
||||
let el_version = get_el_version(&state.config.el_binary);
|
||||
|
||||
Json(StatusResponse {
|
||||
project_name,
|
||||
@@ -34,9 +36,26 @@ pub async fn status(State(state): State<AppState>) -> Json<StatusResponse> {
|
||||
file_count,
|
||||
el_file_count,
|
||||
version: "0.1.0".into(),
|
||||
el_version,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_el_version(binary: &str) -> Option<String> {
|
||||
let out = std::process::Command::new(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
let err = err.trim().to_string();
|
||||
if err.is_empty() { None } else { Some(err) }
|
||||
} else {
|
||||
Some(text.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn should_skip_dir(name: &str) -> bool {
|
||||
matches!(name, "target" | ".git" | "node_modules" | "dist" | ".cargo")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ pub struct Config {
|
||||
pub project_path: String,
|
||||
/// Engram server URL for reasoning (EL_ENGRAM_URL, default http://localhost:8742)
|
||||
pub engram_url: String,
|
||||
/// El compiler binary path (EL_BINARY, default "el")
|
||||
pub el_binary: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -21,6 +23,8 @@ impl Config {
|
||||
.unwrap_or_else(|_| ".".into()),
|
||||
engram_url: std::env::var("EL_ENGRAM_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8742".into()),
|
||||
el_binary: std::env::var("EL_BINARY")
|
||||
.unwrap_or_else(|_| "el".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+199
-16
@@ -1168,6 +1168,32 @@ body {
|
||||
outline: none;
|
||||
}
|
||||
#search-input:focus { border-color: var(--accent); }
|
||||
#search-replace-input {
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
outline: none;
|
||||
}
|
||||
#search-replace-input:focus { border-color: var(--accent); }
|
||||
.search-toggle-btn {
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text2);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 11px;
|
||||
padding: 0 7px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.search-toggle-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||
.search-toggle-btn.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
|
||||
.search-result {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -1406,7 +1432,11 @@ body {
|
||||
display: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
#sticky-scroll { cursor: pointer; }
|
||||
#sticky-scroll:hover { background: var(--bg2); }
|
||||
#sticky-scroll .sticky-kind { color: var(--accent); margin-right: 4px; }
|
||||
#sticky-scroll .sticky-name { color: var(--text); }
|
||||
#sticky-scroll .sticky-goto { color: var(--text3); margin-left: 6px; font-size: 10px; }
|
||||
#sticky-scroll.visible { display: block; }
|
||||
|
||||
/* Inline diff panel */
|
||||
@@ -1699,6 +1729,8 @@ body {
|
||||
<div id="file-tree">
|
||||
<div class="panel-header" style="display:flex;align-items:center;gap:4px;">
|
||||
<span style="flex:1">Files</span>
|
||||
<button class="graph-btn" id="btn-new-file" title="New file" style="width:18px;height:18px;font-size:13px;line-height:1;">+</button>
|
||||
<button class="graph-btn" id="btn-refresh-tree" title="Refresh file tree" style="width:18px;height:18px;font-size:11px;">↺</button>
|
||||
<button class="graph-btn" id="btn-collapse-tree" title="Collapse tree (Ctrl+B)" style="width:18px;height:18px;font-size:10px;">‹</button>
|
||||
</div>
|
||||
<div id="file-list">
|
||||
@@ -1737,6 +1769,10 @@ body {
|
||||
<span id="status-encoding">UTF-8</span>
|
||||
<span class="status-sep">|</span>
|
||||
<span id="status-vim-mode" style="display:none"></span>
|
||||
<span id="status-el-ver" style="color:var(--text3);cursor:default" title="El compiler version"></span>
|
||||
<span id="status-el-ver-sep" class="status-sep" style="display:none">|</span>
|
||||
<span id="status-errors" style="display:none;color:var(--red);cursor:pointer" title="Errors — click to open Problems" onclick="switchBottomTab('problems')"></span>
|
||||
<span id="status-warnings" style="display:none;color:var(--yellow);cursor:pointer" title="Warnings — click to open Problems" onclick="switchBottomTab('problems')"></span>
|
||||
<span id="status-server">● connected</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1856,7 +1892,12 @@ Try: "explain this selection", "suggest an activate query", or ask me anything a
|
||||
<div id="panel-search" class="bottom-panel-content">
|
||||
<div class="search-input-row">
|
||||
<input id="search-input" type="text" placeholder="Search in project...">
|
||||
<button class="search-toggle-btn" id="search-toggle-case" title="Match case (Alt+C)" style="font-size:11px;padding:0 6px;">Aa</button>
|
||||
<button class="search-toggle-btn" id="search-toggle-word" title="Match whole word (Alt+W)" style="font-size:11px;padding:0 6px;">ab</button>
|
||||
<button class="search-toggle-btn" id="search-toggle-regex" title="Use regex (Alt+R)" style="font-size:11px;padding:0 6px;">.*</button>
|
||||
<input id="search-replace-input" type="text" placeholder="Replace..." style="flex:0.7">
|
||||
<button class="btn btn-primary" id="btn-search">Search</button>
|
||||
<button class="btn" id="btn-replace-all" title="Replace all">Replace all</button>
|
||||
</div>
|
||||
<div id="search-results"></div>
|
||||
</div>
|
||||
@@ -3222,9 +3263,30 @@ function updateProblemsPanel(diags) {
|
||||
const panel = document.getElementById('panel-problems');
|
||||
const badge = document.getElementById('problems-badge');
|
||||
const errors = diags.filter(d => d.severity === 'error');
|
||||
const warnings = diags.filter(d => d.severity === 'warning');
|
||||
badge.textContent = errors.length;
|
||||
badge.style.display = errors.length > 0 ? '' : 'none';
|
||||
|
||||
// Update status bar error/warning counts
|
||||
const statusErrors = document.getElementById('status-errors');
|
||||
const statusWarnings = document.getElementById('status-warnings');
|
||||
if (statusErrors) {
|
||||
if (errors.length > 0) {
|
||||
statusErrors.textContent = `✕ ${errors.length}`;
|
||||
statusErrors.style.display = '';
|
||||
} else {
|
||||
statusErrors.style.display = 'none';
|
||||
}
|
||||
}
|
||||
if (statusWarnings) {
|
||||
if (warnings.length > 0) {
|
||||
statusWarnings.textContent = `⚠ ${warnings.length}`;
|
||||
statusWarnings.style.display = '';
|
||||
} else {
|
||||
statusWarnings.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (diags.length === 0) {
|
||||
panel.innerHTML = '<div class="no-problems">No problems detected.</div>';
|
||||
return;
|
||||
@@ -3254,11 +3316,61 @@ function updateProblemsPanel(diags) {
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Search panel
|
||||
// Search panel — with case/word/regex toggles and replace
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
document.getElementById('btn-search').addEventListener('click', runSearch);
|
||||
let searchOpts = { matchCase: false, wholeWord: false, useRegex: false };
|
||||
|
||||
function wireSearchToggle(id, key, shortcut) {
|
||||
const btn = document.getElementById(id);
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', () => {
|
||||
searchOpts[key] = !searchOpts[key];
|
||||
btn.classList.toggle('active', searchOpts[key]);
|
||||
});
|
||||
}
|
||||
wireSearchToggle('search-toggle-case', 'matchCase', 'Alt+C');
|
||||
wireSearchToggle('search-toggle-word', 'wholeWord', 'Alt+W');
|
||||
wireSearchToggle('search-toggle-regex', 'useRegex', 'Alt+R');
|
||||
|
||||
// Alt+C/W/R keyboard shortcuts in search panel
|
||||
document.getElementById('search-input').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') runSearch();
|
||||
if (e.key === 'Enter') { runSearch(); return; }
|
||||
if (e.altKey && e.key.toLowerCase() === 'c') { document.getElementById('search-toggle-case').click(); return; }
|
||||
if (e.altKey && e.key.toLowerCase() === 'w') { document.getElementById('search-toggle-word').click(); return; }
|
||||
if (e.altKey && e.key.toLowerCase() === 'r') { document.getElementById('search-toggle-regex').click(); return; }
|
||||
});
|
||||
|
||||
document.getElementById('btn-search').addEventListener('click', runSearch);
|
||||
|
||||
document.getElementById('btn-replace-all').addEventListener('click', async () => {
|
||||
const query = document.getElementById('search-input').value.trim();
|
||||
const replaceStr = document.getElementById('search-replace-input').value;
|
||||
if (!query) return;
|
||||
// Replace in currently open file
|
||||
if (!editor || activeTabId === null) { showToast('No file open'); return; }
|
||||
const tab = tabs.find(t => t.id === activeTabId);
|
||||
if (!tab) return;
|
||||
const content = editor.state.doc.toString();
|
||||
let newContent;
|
||||
try {
|
||||
if (searchOpts.useRegex) {
|
||||
const flags = searchOpts.matchCase ? 'g' : 'gi';
|
||||
newContent = content.replace(new RegExp(query, flags), replaceStr);
|
||||
} else {
|
||||
let pattern = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (searchOpts.wholeWord) pattern = `\\b${pattern}\\b`;
|
||||
const flags = searchOpts.matchCase ? 'g' : 'gi';
|
||||
newContent = content.replace(new RegExp(pattern, flags), replaceStr);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Invalid regex: ' + err.message);
|
||||
return;
|
||||
}
|
||||
if (newContent === content) { showToast('No matches'); return; }
|
||||
editor.dispatch({ changes: { from: 0, to: content.length, insert: newContent } });
|
||||
tab.content = newContent;
|
||||
await saveCurrentFile();
|
||||
showToast('Replaced all occurrences');
|
||||
});
|
||||
|
||||
async function runSearch() {
|
||||
@@ -3267,7 +3379,11 @@ async function runSearch() {
|
||||
const results = document.getElementById('search-results');
|
||||
results.innerHTML = '<div style="color:var(--text3)">Searching…</div>';
|
||||
try {
|
||||
const resp = await fetch(`/api/search?query=${encodeURIComponent(query)}`);
|
||||
const params = new URLSearchParams({ query });
|
||||
if (searchOpts.matchCase) params.set('case_sensitive', '1');
|
||||
if (searchOpts.wholeWord) params.set('whole_word', '1');
|
||||
if (searchOpts.useRegex) params.set('regex', '1');
|
||||
const resp = await fetch(`/api/search?${params}`);
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
const data = await resp.json();
|
||||
if (data.length === 0) {
|
||||
@@ -3770,46 +3886,69 @@ document.addEventListener('mouseup', () => { minimapDragging = false; });
|
||||
// Sticky scroll — show current function/type header at top of editor
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
const stickyScrollEl = document.getElementById('sticky-scroll');
|
||||
const STICKY_DEFS = /^(fn |type |enum |protocol |impl )/;
|
||||
const STICKY_DEFS = /^(fn |type |enum |protocol |impl |test |seed )/;
|
||||
|
||||
let _stickyLineNum = null;
|
||||
|
||||
function updateStickyScroll() {
|
||||
if (!editor) return;
|
||||
const cmScroller = document.querySelector('.cm-scroller');
|
||||
if (!cmScroller) return;
|
||||
const scrollTop = cmScroller.scrollTop;
|
||||
const lineH = 20; // approximate
|
||||
const firstVisibleLine = Math.floor(scrollTop / lineH) + 1;
|
||||
|
||||
// Use CM6's actual viewport to determine what line is at top
|
||||
const rect = cmScroller.getBoundingClientRect();
|
||||
const topPos = editor.posAtCoords({ x: rect.left + 1, y: rect.top + 2 });
|
||||
if (topPos == null) {
|
||||
stickyScrollEl.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
const firstVisibleLine = editor.state.doc.lineAt(topPos).number;
|
||||
const doc = editor.state.doc;
|
||||
|
||||
if (firstVisibleLine <= 1) {
|
||||
stickyScrollEl.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the last definition before firstVisibleLine
|
||||
let best = null;
|
||||
for (let i = Math.min(firstVisibleLine - 1, doc.lines); i >= 1; i--) {
|
||||
// Find the last definition line before firstVisibleLine
|
||||
let bestText = null;
|
||||
let bestLineNum = null;
|
||||
for (let i = firstVisibleLine - 1; i >= 1; i--) {
|
||||
try {
|
||||
const line = doc.line(i);
|
||||
const text = line.text.trim();
|
||||
if (STICKY_DEFS.test(text)) {
|
||||
best = text;
|
||||
bestText = text;
|
||||
bestLineNum = i;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
// Extract kind and name
|
||||
const m = best.match(/^(\w+)\s+(\S+)/);
|
||||
if (bestText) {
|
||||
const m = bestText.match(/^(\w+)\s+(\S+)/);
|
||||
if (m) {
|
||||
stickyScrollEl.innerHTML = `<span class="sticky-kind">${escapeHtml(m[1])}</span>${escapeHtml(best.slice(m[1].length + 1))}`;
|
||||
_stickyLineNum = bestLineNum;
|
||||
stickyScrollEl.innerHTML = `<span class="sticky-kind">${escapeHtml(m[1])}</span><span class="sticky-name">${escapeHtml(bestText.slice(m[1].length + 1))}</span><span class="sticky-goto" title="Jump to definition">:${bestLineNum}</span>`;
|
||||
stickyScrollEl.classList.add('visible');
|
||||
}
|
||||
} else {
|
||||
stickyScrollEl.classList.remove('visible');
|
||||
_stickyLineNum = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Click sticky scroll to jump to definition
|
||||
stickyScrollEl.addEventListener('click', () => {
|
||||
if (_stickyLineNum == null) return;
|
||||
try {
|
||||
const lineInfo = editor.state.doc.line(_stickyLineNum);
|
||||
editor.dispatch({ selection: { anchor: lineInfo.from }, scrollIntoView: true });
|
||||
editor.focus();
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Attach scroll listener to CM scroller when it appears
|
||||
function attachStickyScrollListener() {
|
||||
const cmScroller = document.querySelector('.cm-scroller');
|
||||
@@ -4988,6 +5127,14 @@ function toggleFileTree() {
|
||||
}
|
||||
|
||||
document.getElementById('btn-collapse-tree').addEventListener('click', () => toggleFileTree());
|
||||
document.getElementById('btn-new-file').addEventListener('click', () => {
|
||||
const name = prompt('New file name (relative to project root):');
|
||||
if (name) createNewFile(name);
|
||||
});
|
||||
document.getElementById('btn-refresh-tree').addEventListener('click', () => {
|
||||
loadFileTree().then(() => loadGitStatus());
|
||||
showToast('File tree refreshed');
|
||||
});
|
||||
|
||||
(function initFileTreeResize() {
|
||||
const handle = document.getElementById('file-tree-resize');
|
||||
@@ -5216,6 +5363,41 @@ function escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Breadcrumb — wire directory segment clicks to reveal in file tree
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
document.getElementById('breadcrumb').addEventListener('click', e => {
|
||||
const crumb = e.target.closest('.crumb');
|
||||
if (!crumb) return;
|
||||
const dirPath = crumb.dataset.path;
|
||||
if (!dirPath) return;
|
||||
// Expand/scroll to this directory in the file tree
|
||||
const dirItem = document.querySelector(`.file-item.dir[data-path="${CSS.escape(dirPath)}"]`);
|
||||
if (dirItem) {
|
||||
dirItem.scrollIntoView({ block: 'nearest' });
|
||||
// Toggle open if not already
|
||||
if (!dirItem.classList.contains('open')) dirItem.click();
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// El version — fetch from /api/status
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
async function loadElVersion() {
|
||||
try {
|
||||
const resp = await fetch('/api/status');
|
||||
if (!resp.ok) return;
|
||||
const s = await resp.json();
|
||||
if (s.el_version) {
|
||||
const verEl = document.getElementById('status-el-ver');
|
||||
const sepEl = document.getElementById('status-el-ver-sep');
|
||||
verEl.textContent = 'el ' + s.el_version;
|
||||
verEl.title = 'El compiler ' + s.el_version;
|
||||
if (sepEl) sepEl.style.display = '';
|
||||
}
|
||||
} catch { /* silently ignore */ }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Project config
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -5242,6 +5424,7 @@ async function init() {
|
||||
|
||||
// Load settings from server (may override localStorage values)
|
||||
loadSettingsFromApi();
|
||||
loadElVersion();
|
||||
|
||||
await Promise.all([loadProjectConfig(), loadFileTree(), loadServerSnippets()]);
|
||||
loadGitStatus();
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "hello-friends"
|
||||
version = "0.1.0"
|
||||
description = "Hello world — first program in Engram"
|
||||
authors = ["Will Anderson"]
|
||||
edition = "2026"
|
||||
@@ -0,0 +1,21 @@
|
||||
// Hello, Friends — first Engram program
|
||||
// Written for Beth, Tim, and Matt on April 28, 2026.
|
||||
// This is what it looks like to write in a language
|
||||
// that runs inside an intelligent system.
|
||||
|
||||
fn greet(name: String) -> String {
|
||||
return "Hello, " + name + ". Good to have you here."
|
||||
}
|
||||
|
||||
fn main() -> Void {
|
||||
let friends = ["Beth", "Tim", "Matt"]
|
||||
|
||||
for friend in friends {
|
||||
let message = greet(friend)
|
||||
print(message)
|
||||
}
|
||||
|
||||
print("")
|
||||
print("Engram is the language of the engine room.")
|
||||
print("You are standing at the edge of something real.")
|
||||
}
|
||||
Reference in New Issue
Block a user