From 7ad87e6785944a4640fdf695dbb127ac62019e71 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 20:58:35 +0200 Subject: [PATCH 01/15] feat: tweak column layout for better preview --- rust/src/ui.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/rust/src/ui.rs b/rust/src/ui.rs index 8b54fd3..ad6695a 100644 --- a/rust/src/ui.rs +++ b/rust/src/ui.rs @@ -10,21 +10,22 @@ use ratatui::Frame; pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(1), - Constraint::Length(1), - ]) + .constraints([Constraint::Min(1), Constraint::Length(1)]) .split(f.area()); - let query = Paragraph::new(format!("> {}", app.query)) - .block(Block::default().borders(Borders::ALL).title("query")); - f.render_widget(query, chunks[0]); - let mid = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) - .split(chunks[1]); + .split(chunks[0]); + + let left = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(1)]) + .split(mid[0]); + + let query = Paragraph::new(format!("> {}", app.query)) + .block(Block::default().borders(Borders::ALL).title("query")); + f.render_widget(query, left[0]); let items: Vec = app .results @@ -51,7 +52,7 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { let list = List::new(items) .block(Block::default().borders(Borders::ALL).title("results")) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - f.render_stateful_widget(list, mid[0], &mut state); + f.render_stateful_widget(list, left[1], &mut state); let preview_lines: Vec = preview .lines @@ -71,5 +72,5 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { f.render_widget(preview_widget, mid[1]); let status = Paragraph::new(app.status.clone()).style(Style::default().fg(Color::DarkGray)); - f.render_widget(status, chunks[2]); + f.render_widget(status, chunks[1]); } From 02a0a7cd115ceb0bebcc42a53b6375a9c530ae97 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:04:48 +0200 Subject: [PATCH 02/15] Add design spec for empty-query directory listing --- ...07-empty-query-directory-listing-design.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-07-empty-query-directory-listing-design.md diff --git a/docs/superpowers/specs/2026-07-07-empty-query-directory-listing-design.md b/docs/superpowers/specs/2026-07-07-empty-query-directory-listing-design.md new file mode 100644 index 0000000..1af6b9c --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-empty-query-directory-listing-design.md @@ -0,0 +1,168 @@ +# Empty Query Shows Directory Contents + +## Problem + +Today, when the search query is empty, `srchr` shows nothing: + +- At startup with no `-q` flag (or `-q ""`), no search is scheduled and the + result list stays empty (`main.rs`, `run()`, seed-empty branch). +- If the user types a query and then backspaces it down to empty, + `Action::QueryChanged` clears results to `Vec::new()` and blanks the status + line instead of running anything (`main.rs`, `QueryChanged` handling). + +This means a user who launches `srchr` and hasn't typed anything yet sees a +blank screen with no sense of what's in the directory. An empty query should +instead browse the whole tree, exactly like `ls -R` combined with gitignore +awareness — reusing the same walk that content/name search already performs. + +This spec also fixes a related cosmetic issue: paths produced when the search +root is `.` include a `./` prefix (e.g. `./src/main.rs`), which is not +useful in the result list and will be stripped for all rows, not just the new +directory listing. + +## Relationship to prior specs + +`docs/superpowers/specs/2026-07-07-prefill-query-design.md` states: *"`-q ""` +... behaves identically to no `-q`: empty query, no startup search, empty +status."* This spec supersedes that specific claim: an empty query (via `-q +""` or no `-q` at all) now triggers a directory listing at startup, just like +backspacing to empty triggers one during interactive use. The "identical +behavior between `-q ""` and no `-q`" invariant itself still holds — both now +schedule the same listing. + +## Design + +### 1. `search.rs`: new `list_dir` function + +```rust +/// Walk `root` (gitignore-aware), producing one FileHit per file with no +/// query applied — used when the search query is empty to browse the whole +/// tree. Returns empty if `cancel` is set. Cancellation is checked per entry. +pub fn list_dir(root: &Path, cancel: &Arc) -> Vec +``` + +- Uses the same `WalkBuilder::new(root).require_git(false)` setup as + `search()`, with the same per-entry cancellation check and the same + "skip unreadable entries silently" behavior. +- Skips name/content matching entirely. Every file becomes: + ```rust + FileHit { path: normalize_path(root, path), match_count: 0, first_line: None } + ``` +- Calls `sort_hits` before returning. Since every entry is a name-only tie + (`match_count: 0`, `first_line: None`), `sort_hits`'s tie-break by path + naturally yields alphabetical-by-path ordering — matching the existing + `[name]`-row rendering with no changes needed in `ui.rs`. + +### 2. Path normalization helper (used by both `search()` and `list_dir()`) + +```rust +/// Strip a leading "./" path component so rows read "src/main.rs" instead of +/// "./src/main.rs" when the root is ".". Paths built from other roots are +/// unaffected (they never gain this prefix from WalkBuilder). +fn normalize_path(path: &Path) -> PathBuf { + path.strip_prefix(".").unwrap_or(path).to_path_buf() +} +``` + +`search()` will apply this when constructing each `FileHit` (currently +`path.to_path_buf()` at `search.rs:113`), and `list_dir()` will apply it the +same way. No changes needed to `editor.rs`'s existing `+`/`-` guard, since +that guard already rewrites relative paths defensively regardless of `./` +prefix. + +### 3. `main.rs`: unify empty and non-empty query handling + +Both call sites that currently special-case "query is empty" will instead +always schedule a background job — the job itself picks `list_dir` vs. +`search` based on whether the query string is empty. + +**`spawn_search`** (`main.rs:227`) gains a branch: + +```rust +fn spawn_search(query: &str, root: &Path, tx: Sender) -> Arc { + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = Arc::clone(&cancel); + let query = query.to_string(); + let root = root.to_path_buf(); + thread::spawn(move || { + let (hits, error) = if query.is_empty() { + (list_dir(&root, &worker_cancel), None) + } else { + match Query::compile(&query) { + Ok(q) => (search(&q, &root, &worker_cancel), None), + Err(e) => (Vec::new(), Some(e)), + } + }; + let _ = tx.send(SearchResult { query, hits, error }); + }); + cancel +} +``` + +**Startup** (`main.rs:68-78`): remove the `if !seed.is_empty()` guard so a job +is always scheduled, empty seed or not: + +```rust +let seed = initial_query.unwrap_or_default(); +let mut app = App::with_query(seed.clone()); +... +pending_query = Some(seed); +pending_at = Instant::now() - DEBOUNCE; +app.status = "searching...".to_string(); +``` + +**`Action::QueryChanged`** (`main.rs:126-139`): remove the +`if app.query.is_empty() { ... } else { ... }` split; always schedule the +debounced job the same way: + +```rust +Action::QueryChanged => { + if let Some(cancel) = current_cancel.take() { + cancel.store(true, Ordering::Relaxed); + } + pending_query = Some(app.query.clone()); + pending_at = Instant::now(); + app.status = "searching...".to_string(); +} +``` + +The existing debounce loop (`main.rs:96-104`) and result-application logic +(`main.rs:86-94`) are unchanged — they already handle arbitrary query +strings, including empty ones, and already set status to +`"searching..."` while in flight and `"{n} files"` once results land. No new +status text is introduced. + +### 4. No changes to `app.rs` or `ui.rs` + +`FileHit` rows with `first_line: None` already render as `[name]` regardless +of how they were produced. `App` has no knowledge of which function produced +its `results`. + +## Testing + +- `search.rs`: unit tests for `list_dir` + - returns one `FileHit` per file in a temp directory tree, `match_count: 0`, + `first_line: None`. + - respects `.gitignore` (ignored files excluded). + - returns `Vec::new()` when `cancel` is already set. + - `normalize_path` strips a leading `./` component; leaves other paths + unchanged. +- `main.rs`: extend/adjust existing tests around `QueryChanged` handling and + startup scheduling to confirm: + - an empty seed at startup still results in `pending_query = Some("")` + (schedules a job) rather than `None`. + - backspacing a query to empty produces `Action::QueryChanged` and (via the + unified handling) schedules a job rather than synchronously clearing + results. +- Existing `search()` tests continue to pass with `normalize_path` applied + (paths in those tests use temp dirs, not `.`, so `./` stripping is a no-op + for them — verify no regressions). + +## Non-goals + +- No shallow/`ls`-style single-level listing — the directory listing is fully + recursive, matching the existing `search()` walk. +- No new status text for the listing state; it reuses `"searching..."` and + `"{n} files"`. +- No change to sort behavior beyond what `sort_hits` already does for + name-only ties (alphabetical by path). From 92cf975732df62a82d069bbf90d079bafe657ced Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:09:36 +0200 Subject: [PATCH 03/15] feat: add normalize_path helper to strip leading ./ from paths --- rust/src/search.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/rust/src/search.rs b/rust/src/search.rs index 03fe39d..311272f 100644 --- a/rust/src/search.rs +++ b/rust/src/search.rs @@ -75,6 +75,13 @@ pub fn name_matches(query: &Query, path: &Path) -> bool { } } +/// Strip a leading "./" path component so rows read "src/main.rs" instead of +/// "./src/main.rs" when the search root is ".". Paths built from other roots +/// are unaffected, since they never gain this prefix from `WalkBuilder`. +fn normalize_path(path: &Path) -> PathBuf { + path.strip_prefix(".").unwrap_or(path).to_path_buf() +} + /// Content matches first (by descending count), then name-only; ties by path. pub fn sort_hits(hits: &mut [FileHit]) { hits.sort_by(|a, b| { @@ -193,6 +200,26 @@ mod tests { assert!(!name_matches(&q2, Path::new("readme.md"))); } + #[test] + fn normalize_path_strips_leading_dot_slash() { + assert_eq!( + normalize_path(Path::new("./src/main.rs")), + PathBuf::from("src/main.rs") + ); + } + + #[test] + fn normalize_path_leaves_other_paths_unchanged() { + assert_eq!( + normalize_path(Path::new("src/main.rs")), + PathBuf::from("src/main.rs") + ); + assert_eq!( + normalize_path(Path::new("/tmp/foo/bar.rs")), + PathBuf::from("/tmp/foo/bar.rs") + ); + } + #[test] fn ordering_content_before_name_only_then_by_count() { let mut hits = vec![ From d106cec1aee7d891a8d4cdd19c16d6b2c8e2f98c Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:10:01 +0200 Subject: [PATCH 04/15] feat: strip leading ./ from search() result paths --- rust/src/search.rs | 2 +- rust/tests/search_tests.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/rust/src/search.rs b/rust/src/search.rs index 311272f..5ef988a 100644 --- a/rust/src/search.rs +++ b/rust/src/search.rs @@ -117,7 +117,7 @@ pub fn search(query: &Query, root: &Path, cancel: &Arc) -> Vec 0 || name_hit { hits.push(FileHit { - path: path.to_path_buf(), + path: normalize_path(path), match_count: count, first_line: first, }); diff --git a/rust/tests/search_tests.rs b/rust/tests/search_tests.rs index 4f12eaf..eb24bd2 100644 --- a/rust/tests/search_tests.rs +++ b/rust/tests/search_tests.rs @@ -45,6 +45,20 @@ fn merges_name_and_content_hits_deduped() { assert_eq!(hits.len(), 3); } +#[test] +fn search_strips_leading_dot_slash_from_root() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "alpha.rs", "alpha token\n"); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir.path()).unwrap(); + let q = Query::compile("alpha").unwrap(); + let hits = search(&q, Path::new("."), &cancel_never()); + std::env::set_current_dir(cwd).unwrap(); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].path, std::path::PathBuf::from("alpha.rs")); +} + #[test] fn respects_gitignore() { let dir = tempfile::tempdir().unwrap(); From a93c25375225fcaabdd831b551c2de756bf73e95 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:10:33 +0200 Subject: [PATCH 05/15] feat: add list_dir to browse the whole tree with no query --- rust/src/search.rs | 28 ++++++++++++++++++ rust/tests/search_tests.rs | 59 +++++++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/rust/src/search.rs b/rust/src/search.rs index 5ef988a..a698fe4 100644 --- a/rust/src/search.rs +++ b/rust/src/search.rs @@ -128,6 +128,34 @@ pub fn search(query: &Query, root: &Path, cancel: &Arc) -> Vec) -> Vec { + let mut hits: Vec = Vec::new(); + + for result in WalkBuilder::new(root).require_git(false).build() { + if cancel.load(Ordering::Relaxed) { + return Vec::new(); + } + let entry = match result { + Ok(e) => e, + Err(_) => continue, // skip unreadable entries silently + }; + if !entry.file_type().is_some_and(|ft| ft.is_file()) { + continue; + } + hits.push(FileHit { + path: normalize_path(entry.path()), + match_count: 0, + first_line: None, + }); + } + + sort_hits(&mut hits); + hits +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/tests/search_tests.rs b/rust/tests/search_tests.rs index eb24bd2..35cb79c 100644 --- a/rust/tests/search_tests.rs +++ b/rust/tests/search_tests.rs @@ -1,4 +1,4 @@ -use srchr::search::{search, Query}; +use srchr::search::{list_dir, search, Query}; use std::io::Write; use std::path::Path; @@ -84,3 +84,60 @@ fn cancel_flag_returns_empty() { let hits = search(&q, dir.path(), &cancel); assert!(hits.is_empty()); } + +#[test] +fn list_dir_returns_all_files_as_name_only_hits() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "a.rs", "hello\n"); + write(dir.path(), "b.txt", "world\n"); + let hits = list_dir(dir.path(), &cancel_never()); + + let names: Vec<_> = hits + .iter() + .map(|h| h.path.file_name().unwrap().to_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"a.rs".to_string())); + assert!(names.contains(&"b.txt".to_string())); + for h in &hits { + assert_eq!(h.match_count, 0); + assert_eq!(h.first_line, None); + } +} + +#[test] +fn list_dir_respects_gitignore() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), ".gitignore", "ignored/\n"); + write(dir.path(), "ignored/secret.rs", "x\n"); + write(dir.path(), "kept.rs", "x\n"); + let hits = list_dir(dir.path(), &cancel_never()); + let names: Vec<_> = hits + .iter() + .map(|h| h.path.file_name().unwrap().to_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"kept.rs".to_string())); + assert!(!names.contains(&"secret.rs".to_string())); +} + +#[test] +fn list_dir_cancel_flag_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "a.rs", "hello\n"); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let hits = list_dir(dir.path(), &cancel); + assert!(hits.is_empty()); +} + +#[test] +fn list_dir_sorts_alphabetically_by_path() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "z.rs", "x\n"); + write(dir.path(), "a.rs", "x\n"); + write(dir.path(), "m.rs", "x\n"); + let hits = list_dir(dir.path(), &cancel_never()); + let names: Vec<_> = hits + .iter() + .map(|h| h.path.file_name().unwrap().to_str().unwrap().to_string()) + .collect(); + assert_eq!(names, vec!["a.rs", "m.rs", "z.rs"]); +} From dd770cec81db6aee6a786ff3c6cde3dfe010b370 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:11:06 +0200 Subject: [PATCH 06/15] feat: spawn_search lists directory contents for empty query --- rust/src/main.rs | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/rust/src/main.rs b/rust/src/main.rs index ff86ce0..8c199b7 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -19,7 +19,7 @@ use clap::Parser; use srchr::app::App; use srchr::editor; use srchr::preview::{build_preview_safe, style_preview, PreviewData, StyledPreview}; -use srchr::search::{search, FileHit, Query}; +use srchr::search::{list_dir, search, FileHit, Query}; const DEBOUNCE: Duration = Duration::from_millis(60); const POLL_INTERVAL: Duration = Duration::from_millis(30); @@ -230,9 +230,13 @@ fn spawn_search(query: &str, root: &Path, tx: Sender) -> Arc (search(&q, &root, &worker_cancel), None), - Err(e) => (Vec::new(), Some(e)), + let (hits, error) = if query.is_empty() { + (list_dir(&root, &worker_cancel), None) + } else { + match Query::compile(&query) { + Ok(q) => (search(&q, &root, &worker_cancel), None), + Err(e) => (Vec::new(), Some(e)), + } }; let _ = tx.send(SearchResult { query, hits, error }); }); @@ -307,4 +311,29 @@ mod tests { assert_eq!(cli.path, PathBuf::from("src")); assert_eq!(cli.query.as_deref(), Some("fn")); } + + #[test] + fn spawn_search_with_empty_query_lists_directory() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.rs"), "hello").unwrap(); + std::fs::write(dir.path().join("b.txt"), "world").unwrap(); + + let (tx, rx) = mpsc::channel(); + let _cancel = spawn_search("", dir.path(), tx); + let res = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + + assert_eq!(res.query, ""); + assert!(res.error.is_none()); + let names: Vec<_> = res + .hits + .iter() + .map(|h| h.path.file_name().unwrap().to_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"a.rs".to_string())); + assert!(names.contains(&"b.txt".to_string())); + for h in &res.hits { + assert_eq!(h.match_count, 0); + assert_eq!(h.first_line, None); + } + } } From 9fff6267c5c584c90ef5dd985d290e35bda3d9ea Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:11:22 +0200 Subject: [PATCH 07/15] feat: always schedule a startup job, even for an empty seed query --- rust/src/main.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/rust/src/main.rs b/rust/src/main.rs index 8c199b7..2b7d35b 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -68,14 +68,9 @@ fn run(root: PathBuf, initial_query: Option) -> io::Result<()> { let seed = initial_query.unwrap_or_default(); let mut app = App::with_query(seed.clone()); let (result_tx, result_rx): (Sender, Receiver) = mpsc::channel(); - let mut pending_query: Option = None; - let mut pending_at = Instant::now(); - - if !seed.is_empty() { - pending_query = Some(seed); - pending_at = Instant::now() - DEBOUNCE; - app.status = "searching...".to_string(); - } + let mut pending_query: Option = Some(seed); + let mut pending_at = Instant::now() - DEBOUNCE; + app.status = "searching...".to_string(); let mut current_cancel: Option> = None; let mut launch_target: Option<(String, Option)> = None; let mut preview_key: Option<(PathBuf, Option)> = None; From eac439d512e4eff8309de30aacea1333d407fb6e Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:11:37 +0200 Subject: [PATCH 08/15] feat: schedule directory listing when query is backspaced to empty --- ...026-07-07-empty-query-directory-listing.md | 609 ++++++++++++++++++ rust/src/main.rs | 12 +- 2 files changed, 612 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-07-empty-query-directory-listing.md diff --git a/docs/superpowers/plans/2026-07-07-empty-query-directory-listing.md b/docs/superpowers/plans/2026-07-07-empty-query-directory-listing.md new file mode 100644 index 0000000..30cd4cc --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-empty-query-directory-listing.md @@ -0,0 +1,609 @@ +# Empty Query Shows Directory Contents Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When the search query is empty, `srchr` lists all files under the search root (recursive, gitignore-aware) instead of showing a blank result list, and paths no longer show a leading `./` prefix. + +**Architecture:** Add a `list_dir` function to `search.rs` that walks the tree the same way `search()` does but without any name/content matching, producing name-only `FileHit` rows. Add a shared `normalize_path` helper to strip a leading `./` path component, used by both `search()` and `list_dir()`. Wire `main.rs` so the empty-query case runs through the same debounce/spawn machinery as any other query, picking `list_dir` vs. `search` based on whether the query string is empty. + +**Tech Stack:** Rust, `ignore` crate (gitignore-aware walking), existing `srchr::search` module. + +**Spec:** `docs/superpowers/specs/2026-07-07-empty-query-directory-listing-design.md` + +--- + +### Task 1: Add `normalize_path` helper with tests + +**Files:** +- Modify: `rust/src/search.rs` + +- [ ] **Step 1: Write the failing tests** + +Add to the `mod tests` block in `rust/src/search.rs` (after the existing `filename_match_is_smart_case` test, before `ordering_content_before_name_only_then_by_count`): + +```rust + #[test] + fn normalize_path_strips_leading_dot_slash() { + assert_eq!( + normalize_path(Path::new("./src/main.rs")), + PathBuf::from("src/main.rs") + ); + } + + #[test] + fn normalize_path_leaves_other_paths_unchanged() { + assert_eq!( + normalize_path(Path::new("src/main.rs")), + PathBuf::from("src/main.rs") + ); + assert_eq!( + normalize_path(Path::new("/tmp/foo/bar.rs")), + PathBuf::from("/tmp/foo/bar.rs") + ); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --manifest-path rust/Cargo.toml normalize_path` +Expected: FAIL with "cannot find function `normalize_path` in this scope" + +- [ ] **Step 3: Implement `normalize_path`** + +Add this function to `rust/src/search.rs`, directly above `sort_hits` (before line 78, `/// Content matches first...`): + +```rust +/// Strip a leading "./" path component so rows read "src/main.rs" instead of +/// "./src/main.rs" when the search root is ".". Paths built from other roots +/// are unaffected, since they never gain this prefix from `WalkBuilder`. +fn normalize_path(path: &Path) -> PathBuf { + path.strip_prefix(".").unwrap_or(path).to_path_buf() +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --manifest-path rust/Cargo.toml normalize_path` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +cd /home/dh/projects/srchr +git add rust/src/search.rs +git commit -m "feat: add normalize_path helper to strip leading ./ from paths" +``` + +--- + +### Task 2: Apply `normalize_path` in `search()` + +**Files:** +- Modify: `rust/src/search.rs:112-116` +- Test: `rust/tests/search_tests.rs` + +- [ ] **Step 1: Write the failing test** + +Add to `rust/tests/search_tests.rs` (after `merges_name_and_content_hits_deduped`): + +```rust +#[test] +fn search_strips_leading_dot_slash_from_root() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "alpha.rs", "alpha token\n"); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir.path()).unwrap(); + let q = Query::compile("alpha").unwrap(); + let hits = search(&q, Path::new("."), &cancel_never()); + std::env::set_current_dir(cwd).unwrap(); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].path, std::path::PathBuf::from("alpha.rs")); +} +``` + +Note: this test changes the process's current directory temporarily. It must +not run concurrently with other tests that depend on `std::env::current_dir`. +No other test in this suite reads `current_dir`, so this is safe as written. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --manifest-path rust/Cargo.toml search_strips_leading_dot_slash_from_root` +Expected: FAIL — `hits[0].path` is `./alpha.rs`, not `alpha.rs` + +- [ ] **Step 3: Apply `normalize_path` in `search()`** + +In `rust/src/search.rs`, change the `FileHit` construction inside `search()` +(currently at lines 112-116): + +```rust + if count > 0 || name_hit { + hits.push(FileHit { + path: path.to_path_buf(), + match_count: count, + first_line: first, + }); + } +``` + +to: + +```rust + if count > 0 || name_hit { + hits.push(FileHit { + path: normalize_path(path), + match_count: count, + first_line: first, + }); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test --manifest-path rust/Cargo.toml --test search_tests` +Expected: PASS (all tests in `search_tests.rs`, including the new one and the +pre-existing `merges_name_and_content_hits_deduped`, `respects_gitignore`, +`cancel_flag_returns_empty`) + +- [ ] **Step 5: Commit** + +```bash +cd /home/dh/projects/srchr +git add rust/src/search.rs rust/tests/search_tests.rs +git commit -m "feat: strip leading ./ from search() result paths" +``` + +--- + +### Task 3: Add `list_dir` function with tests + +**Files:** +- Modify: `rust/src/search.rs` +- Test: `rust/tests/search_tests.rs` + +- [ ] **Step 1: Write the failing tests** + +Add to `rust/tests/search_tests.rs`, update the import line at the top from: + +```rust +use srchr::search::{search, Query}; +``` + +to: + +```rust +use srchr::search::{list_dir, search, Query}; +``` + +Then add these tests at the end of the file: + +```rust +#[test] +fn list_dir_returns_all_files_as_name_only_hits() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "a.rs", "hello\n"); + write(dir.path(), "b.txt", "world\n"); + let hits = list_dir(dir.path(), &cancel_never()); + + let names: Vec<_> = hits + .iter() + .map(|h| h.path.file_name().unwrap().to_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"a.rs".to_string())); + assert!(names.contains(&"b.txt".to_string())); + for h in &hits { + assert_eq!(h.match_count, 0); + assert_eq!(h.first_line, None); + } +} + +#[test] +fn list_dir_respects_gitignore() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), ".gitignore", "ignored/\n"); + write(dir.path(), "ignored/secret.rs", "x\n"); + write(dir.path(), "kept.rs", "x\n"); + let hits = list_dir(dir.path(), &cancel_never()); + let names: Vec<_> = hits + .iter() + .map(|h| h.path.file_name().unwrap().to_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"kept.rs".to_string())); + assert!(!names.contains(&"secret.rs".to_string())); +} + +#[test] +fn list_dir_cancel_flag_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "a.rs", "hello\n"); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let hits = list_dir(dir.path(), &cancel); + assert!(hits.is_empty()); +} + +#[test] +fn list_dir_sorts_alphabetically_by_path() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "z.rs", "x\n"); + write(dir.path(), "a.rs", "x\n"); + write(dir.path(), "m.rs", "x\n"); + let hits = list_dir(dir.path(), &cancel_never()); + let names: Vec<_> = hits + .iter() + .map(|h| h.path.file_name().unwrap().to_str().unwrap().to_string()) + .collect(); + assert_eq!(names, vec!["a.rs", "m.rs", "z.rs"]); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --manifest-path rust/Cargo.toml --test search_tests list_dir` +Expected: FAIL with "cannot find function `list_dir`" / unresolved import + +- [ ] **Step 3: Implement `list_dir`** + +Add this function to `rust/src/search.rs`, directly below the `search()` +function (after its closing brace, before the `#[cfg(test)]` block): + +```rust +/// Walk `root` (gitignore-aware), producing one FileHit per file with no +/// query applied. Used when the search query is empty, to browse the whole +/// tree. Returns empty if `cancel` is set. Cancellation is checked per entry. +pub fn list_dir(root: &Path, cancel: &Arc) -> Vec { + let mut hits: Vec = Vec::new(); + + for result in WalkBuilder::new(root).require_git(false).build() { + if cancel.load(Ordering::Relaxed) { + return Vec::new(); + } + let entry = match result { + Ok(e) => e, + Err(_) => continue, // skip unreadable entries silently + }; + if !entry.file_type().is_some_and(|ft| ft.is_file()) { + continue; + } + hits.push(FileHit { + path: normalize_path(entry.path()), + match_count: 0, + first_line: None, + }); + } + + sort_hits(&mut hits); + hits +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --manifest-path rust/Cargo.toml --test search_tests` +Expected: PASS (all tests in `search_tests.rs`) + +- [ ] **Step 5: Commit** + +```bash +cd /home/dh/projects/srchr +git add rust/src/search.rs rust/tests/search_tests.rs +git commit -m "feat: add list_dir to browse the whole tree with no query" +``` + +--- + +### Task 4: Wire `spawn_search` to use `list_dir` for empty queries + +**Files:** +- Modify: `rust/src/main.rs:22` (import), `rust/src/main.rs:227-240` (`spawn_search`) + +- [ ] **Step 1: Write the failing test** + +Add to the `mod tests` block in `rust/src/main.rs` (after `cli_parses_path_and_query`, at the end of the file): + +```rust + #[test] + fn spawn_search_with_empty_query_lists_directory() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.rs"), "hello").unwrap(); + std::fs::write(dir.path().join("b.txt"), "world").unwrap(); + + let (tx, rx) = mpsc::channel(); + let _cancel = spawn_search("", dir.path(), tx); + let res = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + + assert_eq!(res.query, ""); + assert!(res.error.is_none()); + let names: Vec<_> = res + .hits + .iter() + .map(|h| h.path.file_name().unwrap().to_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"a.rs".to_string())); + assert!(names.contains(&"b.txt".to_string())); + for h in &res.hits { + assert_eq!(h.match_count, 0); + assert_eq!(h.first_line, None); + } + } +``` + +This requires `tempfile` as a dev-dependency, which is already declared in +`rust/Cargo.toml`'s `[dev-dependencies]` (shared across the binary and its +tests). No new imports are needed inside `mod tests`: it already starts with +`use super::*;`, which brings in `Duration` and `mpsc` from the top of +`main.rs` since child modules can see private items of their parent module. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --manifest-path rust/Cargo.toml spawn_search_with_empty_query_lists_directory` +Expected: FAIL — with the current implementation, `Query::compile("")` +succeeds (empty pattern matches everything) and `search()` runs instead of +`list_dir()`, so `match_count` will be nonzero and `first_line` will be +`Some(_)` for at least one file, failing the `assert_eq!(h.match_count, 0)` +assertion. + +- [ ] **Step 3: Update `spawn_search` to branch on empty query** + +In `rust/src/main.rs`, update the import at line 22 from: + +```rust +use srchr::search::{search, FileHit, Query}; +``` + +to: + +```rust +use srchr::search::{list_dir, search, FileHit, Query}; +``` + +Then change `spawn_search` (currently lines 227-240) from: + +```rust +fn spawn_search(query: &str, root: &Path, tx: Sender) -> Arc { + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = Arc::clone(&cancel); + let query = query.to_string(); + let root = root.to_path_buf(); + thread::spawn(move || { + let (hits, error) = match Query::compile(&query) { + Ok(q) => (search(&q, &root, &worker_cancel), None), + Err(e) => (Vec::new(), Some(e)), + }; + let _ = tx.send(SearchResult { query, hits, error }); + }); + cancel +} +``` + +to: + +```rust +fn spawn_search(query: &str, root: &Path, tx: Sender) -> Arc { + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = Arc::clone(&cancel); + let query = query.to_string(); + let root = root.to_path_buf(); + thread::spawn(move || { + let (hits, error) = if query.is_empty() { + (list_dir(&root, &worker_cancel), None) + } else { + match Query::compile(&query) { + Ok(q) => (search(&q, &root, &worker_cancel), None), + Err(e) => (Vec::new(), Some(e)), + } + }; + let _ = tx.send(SearchResult { query, hits, error }); + }); + cancel +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test --manifest-path rust/Cargo.toml` +Expected: PASS (all tests, including +`spawn_search_with_empty_query_lists_directory`) + +- [ ] **Step 5: Commit** + +```bash +cd /home/dh/projects/srchr +git add rust/src/main.rs +git commit -m "feat: spawn_search lists directory contents for empty query" +``` + +--- + +### Task 5: Schedule a job at startup even for an empty seed query + +**Files:** +- Modify: `rust/src/main.rs:68-78` + +- [ ] **Step 1: Update the startup scheduling code** + +In `rust/src/main.rs`, change (currently lines 68-78): + +```rust + let seed = initial_query.unwrap_or_default(); + let mut app = App::with_query(seed.clone()); + let (result_tx, result_rx): (Sender, Receiver) = mpsc::channel(); + let mut pending_query: Option = None; + let mut pending_at = Instant::now(); + + if !seed.is_empty() { + pending_query = Some(seed); + pending_at = Instant::now() - DEBOUNCE; + app.status = "searching...".to_string(); + } +``` + +to: + +```rust + let seed = initial_query.unwrap_or_default(); + let mut app = App::with_query(seed.clone()); + let (result_tx, result_rx): (Sender, Receiver) = mpsc::channel(); + let mut pending_query: Option = Some(seed); + let mut pending_at = Instant::now() - DEBOUNCE; + app.status = "searching...".to_string(); +``` + +This removes the `if !seed.is_empty()` guard: a background job (search or +directory listing, decided inside `spawn_search`) is now always scheduled to +run immediately on startup (the `Instant::now() - DEBOUNCE` backdating makes +the very first debounce-loop iteration fire it right away, same as before for +non-empty seeds). + +There is no separate unit test for this block, since `run()` drives a live +terminal event loop and isn't unit-tested elsewhere in this codebase (see +`AGENTS.md`: the interactive TUI needs a TTY and agents cannot fully test +it). Verification here is: (a) the project builds and all existing tests +still pass, and (b) the manual smoke test in Task 7 below. + +- [ ] **Step 2: Verify the project builds** + +Run: `cargo build --manifest-path rust/Cargo.toml` +Expected: builds with no errors + +- [ ] **Step 3: Run the full test suite** + +Run: `cargo test --manifest-path rust/Cargo.toml` +Expected: PASS (no regressions) + +- [ ] **Step 4: Commit** + +```bash +cd /home/dh/projects/srchr +git add rust/src/main.rs +git commit -m "feat: always schedule a startup job, even for an empty seed query" +``` + +--- + +### Task 6: Schedule a debounced job on `QueryChanged`, even when the query becomes empty + +**Files:** +- Modify: `rust/src/main.rs:126-139` + +- [ ] **Step 1: Update the `QueryChanged` handling** + +In `rust/src/main.rs`, change (currently lines 126-139): + +```rust + Action::QueryChanged => { + if let Some(cancel) = current_cancel.take() { + cancel.store(true, Ordering::Relaxed); + } + if app.query.is_empty() { + pending_query = None; + app.set_results(Vec::new()); + app.status.clear(); + } else { + pending_query = Some(app.query.clone()); + pending_at = Instant::now(); + app.status = "searching...".to_string(); + } + } +``` + +to: + +```rust + Action::QueryChanged => { + if let Some(cancel) = current_cancel.take() { + cancel.store(true, Ordering::Relaxed); + } + pending_query = Some(app.query.clone()); + pending_at = Instant::now(); + app.status = "searching...".to_string(); + } +``` + +This removes the special case that synchronously cleared results when the +query became empty. Now backspacing to empty schedules a debounced background +job the same way any other query edit does; `spawn_search` (Task 4) will run +`list_dir` for it once the debounce elapses. + +As with Task 5, this branch lives inside `run()`'s event loop and has no +existing unit test harness in this codebase; verification is via the build, +full test suite, and manual smoke test (Task 7). + +- [ ] **Step 2: Verify the project builds** + +Run: `cargo build --manifest-path rust/Cargo.toml` +Expected: builds with no errors + +- [ ] **Step 3: Run the full test suite** + +Run: `cargo test --manifest-path rust/Cargo.toml` +Expected: PASS (no regressions) + +- [ ] **Step 4: Commit** + +```bash +cd /home/dh/projects/srchr +git add rust/src/main.rs +git commit -m "feat: schedule directory listing when query is backspaced to empty" +``` + +--- + +### Task 7: Full verification and manual smoke test + +**Files:** none (verification only) + +- [ ] **Step 1: Run fmt, clippy, and full test suite** + +```bash +cargo fmt --manifest-path rust/Cargo.toml -- --check +cargo clippy --manifest-path rust/Cargo.toml -- -D warnings +cargo test --manifest-path rust/Cargo.toml +``` + +Expected: all three succeed with no diffs, warnings, or failures. If `fmt` +reports a diff, run `cargo fmt --manifest-path rust/Cargo.toml` (without +`--check`) and re-verify, then amend the affected commit(s) or fold the +formatting fix into a new small commit. + +- [ ] **Step 2: Manual smoke test (requires a human with a TTY)** + +Per `AGENTS.md`, the interactive TUI needs a TTY and cannot be fully tested +by an agent. Ask the user to run: + +```bash +cargo run --manifest-path rust/Cargo.toml -- . +``` + +And confirm: +- On launch, with no query typed, the result list immediately shows files + from the current directory tree (not blank), with `[name]` rows, no + `./` prefix on any path, and status reads `"N files"` once loaded. +- Typing a query still filters/searches as before. +- Backspacing the query all the way to empty brings back the full directory + listing (after the short debounce), not a blank list. +- `srchr -q ""` behaves the same as `srchr` with no `-q` flag: directory + listing shown on startup. +- Selecting a listed file and pressing Enter still opens it correctly in + `$EDITOR`. + +- [ ] **Step 3: No commit needed for this task** (verification only; if the + smoke test surfaces an issue, fix it in a new commit and re-run this task). + +--- + +## Self-Review Notes + +- **Spec coverage:** `list_dir` (Task 3), `normalize_path` (Tasks 1–2), + `spawn_search` branching (Task 4), startup scheduling (Task 5), + `QueryChanged` scheduling (Task 6), status text reuse (covered implicitly — + no new status strings introduced, existing `"searching..."` / `"{n} + files"` logic in `run()`'s result-application block is untouched), no + changes to `app.rs`/`ui.rs` (confirmed, no task touches them), manual smoke + test (Task 7) per `AGENTS.md` requirement. All spec sections are covered. +- **Placeholder scan:** no TBD/TODO markers; every step has literal code or + exact commands. +- **Type consistency:** `list_dir(root: &Path, cancel: &Arc) -> + Vec` matches its use in Task 4's `spawn_search` (`list_dir(&root, + &worker_cancel)`) and its test call sites (`list_dir(dir.path(), + &cancel_never())`). `normalize_path(path: &Path) -> PathBuf` matches its use + in both `search()` and `list_dir()`. diff --git a/rust/src/main.rs b/rust/src/main.rs index 2b7d35b..18305d3 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -122,15 +122,9 @@ fn run(root: PathBuf, initial_query: Option) -> io::Result<()> { if let Some(cancel) = current_cancel.take() { cancel.store(true, Ordering::Relaxed); } - if app.query.is_empty() { - pending_query = None; - app.set_results(Vec::new()); - app.status.clear(); - } else { - pending_query = Some(app.query.clone()); - pending_at = Instant::now(); - app.status = "searching...".to_string(); - } + pending_query = Some(app.query.clone()); + pending_at = Instant::now(); + app.status = "searching...".to_string(); } Action::None => {} } From 47e83f0f5790ac62f84c9db02d86d9c2d50ae5ee Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:17:45 +0200 Subject: [PATCH 09/15] feat: remove `>` and rename query to files --- rust/src/ui.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/src/ui.rs b/rust/src/ui.rs index ad6695a..6434bf4 100644 --- a/rust/src/ui.rs +++ b/rust/src/ui.rs @@ -23,8 +23,8 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { .constraints([Constraint::Length(3), Constraint::Min(1)]) .split(mid[0]); - let query = Paragraph::new(format!("> {}", app.query)) - .block(Block::default().borders(Borders::ALL).title("query")); + let query = Paragraph::new(format!("{}", app.query)) + .block(Block::default().borders(Borders::ALL).title("files")); f.render_widget(query, left[0]); let items: Vec = app From 5f43ec1a7f7fd36dd5799c38cda9a0fb5b6071d9 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:24:22 +0200 Subject: [PATCH 10/15] feat: remove tag for file results --- rust/src/ui.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/src/ui.rs b/rust/src/ui.rs index 6434bf4..61163cd 100644 --- a/rust/src/ui.rs +++ b/rust/src/ui.rs @@ -32,10 +32,10 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { .iter() .map(|h| { let path = h.path.to_string_lossy(); - let tag = if h.first_line.is_none() { - "[name]".to_string() - } else { + let tag = if h.first_line.is_some() { format!("({})", h.match_count) + } else { + "".to_string() }; ListItem::new(Line::from(vec![ Span::raw(path.into_owned()), From 164524c5688f3dc70db93e4ace9798c68ba61537 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:32:19 +0200 Subject: [PATCH 11/15] feat: replace pane title "preview" with relative path of previewed file --- rust/src/ui.rs | 47 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/rust/src/ui.rs b/rust/src/ui.rs index 61163cd..6953efa 100644 --- a/rust/src/ui.rs +++ b/rust/src/ui.rs @@ -23,7 +23,7 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { .constraints([Constraint::Length(3), Constraint::Min(1)]) .split(mid[0]); - let query = Paragraph::new(format!("{}", app.query)) + let query = Paragraph::new(app.query.to_string()) .block(Block::default().borders(Borders::ALL).title("files")); f.render_widget(query, left[0]); @@ -67,10 +67,51 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { } }) .collect(); - let preview_widget = Paragraph::new(preview_lines) - .block(Block::default().borders(Borders::ALL).title("preview")); + let preview_widget = Paragraph::new(preview_lines).block( + Block::default() + .borders(Borders::ALL) + .title(preview_title(app)), + ); f.render_widget(preview_widget, mid[1]); let status = Paragraph::new(app.status.clone()).style(Style::default().fg(Color::DarkGray)); f.render_widget(status, chunks[1]); } + +/// Title for the preview pane: the selected file's path, or a fallback +/// label when nothing is selected. +fn preview_title(app: &App) -> String { + match app.selected_hit() { + Some(hit) => hit.path.to_string_lossy().into_owned(), + None => "preview".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::search::FileHit; + use std::path::PathBuf; + + fn hit(name: &str) -> FileHit { + FileHit { + path: PathBuf::from(name), + match_count: 1, + first_line: Some(1), + } + } + + #[test] + fn preview_title_shows_selected_file_path() { + let mut app = App::new(); + app.set_results(vec![hit("src/main.rs"), hit("src/lib.rs")]); + app.selected = 1; + assert_eq!(preview_title(&app), "src/lib.rs"); + } + + #[test] + fn preview_title_falls_back_when_no_results() { + let app = App::new(); + assert_eq!(preview_title(&app), "preview"); + } +} From 22c1d01d8a8221821f9dafdc36692888ababc6fd Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:35:28 +0200 Subject: [PATCH 12/15] feat: align titles to center and add a bit of padding --- rust/src/ui.rs | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/rust/src/ui.rs b/rust/src/ui.rs index 6953efa..ecd5bb5 100644 --- a/rust/src/ui.rs +++ b/rust/src/ui.rs @@ -1,6 +1,6 @@ use crate::app::App; use crate::preview::StyledPreview; -use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::layout::{Alignment, Constraint, Direction, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; @@ -23,8 +23,12 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { .constraints([Constraint::Length(3), Constraint::Min(1)]) .split(mid[0]); - let query = Paragraph::new(app.query.to_string()) - .block(Block::default().borders(Borders::ALL).title("files")); + let query = Paragraph::new(app.query.to_string()).block( + Block::default() + .borders(Borders::ALL) + .title(pad_title("files")) + .title_alignment(Alignment::Center), + ); f.render_widget(query, left[0]); let items: Vec = app @@ -50,7 +54,12 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { state.select(Some(app.selected)); } let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title("results")) + .block( + Block::default() + .borders(Borders::ALL) + .title(pad_title("results")) + .title_alignment(Alignment::Center), + ) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); f.render_stateful_widget(list, left[1], &mut state); @@ -70,7 +79,8 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { let preview_widget = Paragraph::new(preview_lines).block( Block::default() .borders(Borders::ALL) - .title(preview_title(app)), + .title(pad_title(&preview_title(app))) + .title_alignment(Alignment::Center), ); f.render_widget(preview_widget, mid[1]); @@ -87,6 +97,12 @@ fn preview_title(app: &App) -> String { } } +/// Add a space of padding on each side of a pane title so it doesn't +/// touch the border characters. +fn pad_title(title: &str) -> String { + format!(" {} ", title) +} + #[cfg(test)] mod tests { use super::*; @@ -114,4 +130,9 @@ mod tests { let app = App::new(); assert_eq!(preview_title(&app), "preview"); } + + #[test] + fn pad_title_adds_a_space_on_each_side() { + assert_eq!(pad_title("results"), " results "); + } } From 8b5ade4dc906c7cb761ddbf3d204874f37882a72 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:54:03 +0200 Subject: [PATCH 13/15] feat: add basic a basic theme, inspired by Tokyo Night --- rust/src/lib.rs | 1 + rust/src/preview.rs | 85 +++++++++++++++++++++++++++++++++++++++++---- rust/src/theme.rs | 16 +++++++++ rust/src/ui.rs | 63 +++++++++++++++++++++------------ 4 files changed, 136 insertions(+), 29 deletions(-) create mode 100644 rust/src/theme.rs diff --git a/rust/src/lib.rs b/rust/src/lib.rs index bfa129d..54e3c1c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -2,4 +2,5 @@ pub mod app; pub mod editor; pub mod preview; pub mod search; +pub mod theme; pub mod ui; diff --git a/rust/src/preview.rs b/rust/src/preview.rs index c13f472..9f671b5 100644 --- a/rust/src/preview.rs +++ b/rust/src/preview.rs @@ -5,12 +5,19 @@ use once_cell::sync::Lazy; use ratatui::style::{Color as TuiColor, Style}; use ratatui::text::{Line, Span}; use syntect::easy::HighlightLines; -use syntect::highlighting::{Style as SynStyle, ThemeSet}; +use syntect::highlighting::{Style as SynStyle, Theme, ThemeSet}; use syntect::parsing::SyntaxSet; static SYNTAXES: Lazy = Lazy::new(SyntaxSet::load_defaults_newlines); -static THEMES: Lazy = Lazy::new(ThemeSet::load_defaults); -const DEFAULT_THEME: &str = "base16-ocean.dark"; + +/// Bundled Tokyo Night ("Night" variant) syntax theme, sourced from +/// `folke/tokyonight.nvim` (Apache-2.0). See `rust/assets/README.md`. +const TOKYO_NIGHT_THEME_BYTES: &[u8] = include_bytes!("../assets/tokyonight_night.tmTheme"); + +static SYNTAX_THEME: Lazy = Lazy::new(|| { + ThemeSet::load_from_reader(&mut std::io::Cursor::new(TOKYO_NIGHT_THEME_BYTES)) + .expect("bundled tokyonight_night.tmTheme should parse") +}); /// Mirrors the shell `start=$((line > 3 ? line - 3 : 1))`. pub fn preview_start(first_line: Option) -> usize { @@ -27,6 +34,40 @@ pub struct PreviewData { pub highlight: Option, } +const TAB_WIDTH: usize = 4; + +/// Expand tabs to spaces (tracking real column position) and drop any other +/// control characters. +/// +/// ratatui's `Buffer::set_stringn` filters out control characters entirely +/// *without* advancing the write cursor for them. Because ratatui only +/// resets the buffer from two frames ago (not the one about to be rendered +/// into), any cell a widget fails to overwrite can retain stale content from +/// an earlier frame — so a literal tab or other control character in +/// previewed text can corrupt the display with fragments of a previous +/// preview. Preview text must therefore never contain raw control +/// characters. +fn sanitize_line(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut col = 0usize; + for ch in line.chars() { + if ch == '\t' { + let spaces = TAB_WIDTH - (col % TAB_WIDTH); + for _ in 0..spaces { + out.push(' '); + } + col += spaces; + } else if ch.is_control() { + // Drop stray control characters (e.g. an errant \r) rather than + // let them reach the renderer. + } else { + out.push(ch); + col += 1; + } + } + out +} + /// Read up to `max_lines` lines from the file starting at `preview_start`. pub fn build_preview( path: &Path, @@ -44,7 +85,7 @@ pub fn build_preview( if lines.len() >= max_lines { break; } - lines.push((lnum, line.unwrap_or_default())); + lines.push((lnum, sanitize_line(&line.unwrap_or_default()))); } Ok(PreviewData { lines, @@ -104,8 +145,7 @@ pub fn style_preview(data: &PreviewData, file_name: &str) -> StyledPreview { .ok() .flatten() .unwrap_or_else(|| SYNTAXES.find_syntax_plain_text()); - let theme = &THEMES.themes[DEFAULT_THEME]; - let mut hl = HighlightLines::new(syntax, theme); + let mut hl = HighlightLines::new(syntax, &SYNTAX_THEME); let mut out_lines: Vec> = Vec::with_capacity(data.lines.len()); let mut highlight_index: Option = None; @@ -193,6 +233,39 @@ mod tests { assert_eq!(styled.highlight_index, Some(2)); } + #[test] + fn build_preview_expands_tabs_to_spaces() { + // Raw tab characters are control characters that ratatui's renderer + // silently drops (without advancing the cursor), which can corrupt + // the TUI display by leaving stale cells from earlier frames + // on-screen. Preview text must never contain a literal tab. + let dir = tempfile::tempdir().unwrap(); + let p = write(dir.path(), "a.txt", "\tfoo\na\tb\n"); + let data = build_preview(&p, None, 100).unwrap(); + assert!( + data.lines.iter().all(|(_, t)| !t.contains('\t')), + "preview lines must not contain raw tab characters: {:?}", + data.lines + ); + assert_eq!(data.lines[0].1, " foo"); + assert_eq!(data.lines[1].1, "a b"); + } + + #[test] + fn build_preview_strips_other_control_characters() { + let dir = tempfile::tempdir().unwrap(); + let p = write(dir.path(), "a.txt", "a\u{7}b\n"); + let data = build_preview(&p, None, 100).unwrap(); + assert_eq!(data.lines[0].1, "ab"); + } + + #[test] + fn bundled_tokyo_night_theme_parses() { + // Force the lazy static to evaluate; panics (via .expect) if the + // bundled asset is missing or malformed. + Lazy::force(&SYNTAX_THEME); + } + #[test] fn binary_content_yields_placeholder() { let dir = tempfile::tempdir().unwrap(); diff --git a/rust/src/theme.rs b/rust/src/theme.rs new file mode 100644 index 0000000..277ea0a --- /dev/null +++ b/rust/src/theme.rs @@ -0,0 +1,16 @@ +//! Tokyo Night ("Night" variant) color palette used throughout the TUI. + +use ratatui::style::Color; + +/// Pane background. +pub const BG: Color = Color::Rgb(0x1a, 0x1b, 0x26); +/// Background for the currently selected result row. +pub const BG_HIGHLIGHT: Color = Color::Rgb(0x29, 0x2e, 0x42); +/// Background for the matched line in the preview pane. +pub const MATCH_LINE_BG: Color = Color::Rgb(0x2d, 0x3f, 0x76); +/// Primary text color. +pub const FG: Color = Color::Rgb(0xc0, 0xca, 0xf5); +/// Muted text color for secondary/status text. +pub const MUTED: Color = Color::Rgb(0x56, 0x5f, 0x89); +/// Accent color for borders and titles. +pub const ACCENT: Color = Color::Rgb(0x7a, 0xa2, 0xf7); diff --git a/rust/src/ui.rs b/rust/src/ui.rs index ecd5bb5..5d4b87f 100644 --- a/rust/src/ui.rs +++ b/rust/src/ui.rs @@ -1,11 +1,32 @@ use crate::app::App; use crate::preview::StyledPreview; +use crate::theme; use ratatui::layout::{Alignment, Constraint, Direction, Layout}; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use ratatui::Frame; +/// Standard bordered/titled block for a pane, using the Tokyo Night accent +/// color for the border and title. +fn pane_block(title: &str) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::ACCENT)) + .title(pad_title(title)) + .title_style( + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ) + .title_alignment(Alignment::Center) +} + +/// Base pane style: Tokyo Night background/foreground. +fn pane_style() -> Style { + Style::default().bg(theme::BG).fg(theme::FG) +} + /// Render the whole UI. `preview` is the styled preview of the selected row. pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { let chunks = Layout::default() @@ -23,12 +44,9 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { .constraints([Constraint::Length(3), Constraint::Min(1)]) .split(mid[0]); - let query = Paragraph::new(app.query.to_string()).block( - Block::default() - .borders(Borders::ALL) - .title(pad_title("files")) - .title_alignment(Alignment::Center), - ); + let query = Paragraph::new(app.query.to_string()) + .style(pane_style()) + .block(pane_block("files")); f.render_widget(query, left[0]); let items: Vec = app @@ -44,7 +62,7 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { ListItem::new(Line::from(vec![ Span::raw(path.into_owned()), Span::raw(" "), - Span::styled(tag, Style::default().fg(Color::DarkGray)), + Span::styled(tag, Style::default().fg(theme::MUTED)), ])) }) .collect(); @@ -54,13 +72,14 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { state.select(Some(app.selected)); } let list = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title(pad_title("results")) - .title_alignment(Alignment::Center), - ) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + .style(pane_style()) + .block(pane_block("results")) + .highlight_style( + Style::default() + .bg(theme::BG_HIGHLIGHT) + .fg(theme::FG) + .add_modifier(Modifier::BOLD), + ); f.render_stateful_widget(list, left[1], &mut state); let preview_lines: Vec = preview @@ -70,21 +89,19 @@ pub fn render(f: &mut Frame, app: &App, preview: &StyledPreview) { .map(|(i, line)| { if Some(i) == preview.highlight_index { line.clone() - .style(Style::default().bg(Color::Rgb(60, 60, 80))) + .style(Style::default().bg(theme::MATCH_LINE_BG)) } else { line.clone() } }) .collect(); - let preview_widget = Paragraph::new(preview_lines).block( - Block::default() - .borders(Borders::ALL) - .title(pad_title(&preview_title(app))) - .title_alignment(Alignment::Center), - ); + let preview_widget = Paragraph::new(preview_lines) + .style(pane_style()) + .block(pane_block(&preview_title(app))); f.render_widget(preview_widget, mid[1]); - let status = Paragraph::new(app.status.clone()).style(Style::default().fg(Color::DarkGray)); + let status = + Paragraph::new(app.status.clone()).style(Style::default().bg(theme::BG).fg(theme::MUTED)); f.render_widget(status, chunks[1]); } From d3e73c6bab3e0b7496f0b3e5d481473347fd33f6 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:55:01 +0200 Subject: [PATCH 14/15] feat: ignore IDEA IDE files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 434ee8a..7a3f390 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .worktrees +.idea From 26e21063e9684da18c624ebde9e444e3b0557322 Mon Sep 17 00:00:00 2001 From: David Henning Date: Tue, 7 Jul 2026 21:57:55 +0200 Subject: [PATCH 15/15] fix: add missing tokyonight_night.tmTheme asset The bundled syntax theme was referenced via include_bytes! but the rust/assets/ directory was never committed, breaking the build for anyone without the file already on disk (e.g. CI). --- rust/assets/README.md | 10 + rust/assets/tokyonight_night.tmTheme | 1377 ++++++++++++++++++++++++++ 2 files changed, 1387 insertions(+) create mode 100644 rust/assets/README.md create mode 100644 rust/assets/tokyonight_night.tmTheme diff --git a/rust/assets/README.md b/rust/assets/README.md new file mode 100644 index 0000000..27f9df3 --- /dev/null +++ b/rust/assets/README.md @@ -0,0 +1,10 @@ +# Bundled assets + +## `tokyonight_night.tmTheme` + +Sublime Text / TextMate color scheme for the "Night" variant of the +[Tokyo Night](https://github.com/folke/tokyonight.nvim) theme, used by +`preview.rs` to drive syntect syntax highlighting. + +Source: https://github.com/folke/tokyonight.nvim/blob/main/extras/sublime/tokyonight_night.tmTheme +License: Apache License 2.0, Copyright (c) folke (see upstream repo for full text). diff --git a/rust/assets/tokyonight_night.tmTheme b/rust/assets/tokyonight_night.tmTheme new file mode 100644 index 0000000..4078381 --- /dev/null +++ b/rust/assets/tokyonight_night.tmTheme @@ -0,0 +1,1377 @@ + + + + author + Folke Lemaitre (http://github.com/folke) + colorSpaceName + sRGB + name + TokyoNight + semanticClass + enki.theme.tokyo + settings + + + settings + + activeGuide + #363b54 + background + #1a1b26 + caret + #DBC08A + findHighlight + #ffa300 + findHighlightForeground + #000000 + foreground + #c0caf5 + guide + #4f4f5e40 + gutterForeground + #3b415caa + inactiveSelection + #282833 + invisibles + #4f4f5e + lineHighlight + #00000030 + selection + #9D599D40 + selectionBorder + #9D599D + shadow + #00000010 + stackGuide + #4f4f5e60 + tagsOptions + underline + + + + name + Italics - Comments, Storage, Keyword Flow, Vue attributes, Decorators + scope + comment, meta.var.expr storage.type, keyword.control.flow, meta.directive.vue punctuation.separator.key-value.html, meta.directive.vue entity.other.attribute-name.html, tag.decorator.js entity.name.tag.js, tag.decorator.js punctuation.definition.tag.js, storage.modifier + settings + + fontStyle + italic + + + + name + Comment + scope + comment, comment.block.documentation, punctuation.definition.comment + settings + + foreground + #565f89 + + + + name + Comment Doc + scope + comment.block.documentation variable, comment.block.documentation storage, comment.block.documentation punctuation, comment.block.documentation keyword, comment.block.documentation support, comment.block.documentation markup, comment.block.documentation markup.inline.raw.string.markdown, keyword.other.phpdoc.php + settings + + foreground + #565f89 + + + + name + Number, Boolean, Undefined, Null + scope + variable.other.constant, punctuation.definition.constant, constant.language, constant.numeric, support.constant + settings + + foreground + #ff9e64 + + + + name + String, Symbols, Markup Heading + scope + meta.property.lua,string.unquoted.key.lua,support.other.metaproperty.lua,support.other.metaproperty.lua,constant.other.symbol, constant.other.key, markup.heading, meta.attribute-selector + settings + + fontStyle + + foreground + #73daca + + + + name + String + scope + string + settings + + fontStyle + + foreground + #9ece6a + + + + name + Colors + scope + constant.other.color, constant.other.color.rgb-value.hex punctuation.definition.constant + settings + + foreground + #9aa5ce + + + + name + Info + scope + markup.info + settings + + foreground + #0db9d7 + background + #192b38 + + + + name + Warning + scope + markup.warning + settings + + foreground + #e0af68 + background + #2e2a2d + + + + name + Error + scope + markup.error + settings + + foreground + #db4b4b + background + #2d202a + + + + name + Invalid + scope + invalid, invalid.illegal + settings + + foreground + #f7768e + + + + name + Invalid deprecated + scope + invalid.deprecated + settings + + foreground + #bb9af7 + + + + name + Storage Type + scope + storage.type + settings + + foreground + #bb9af7 + + + + name + Storage - modifier, var, const, let + scope + meta.var.expr storage.type, storage.modifier + settings + + foreground + #9d7cd8 + + + + name + Interpolation + scope + punctuation.definition.template-expression, punctuation.section.embedded + settings + + foreground + #7dcfff + + + + name + Spread + scope + keyword.operator.spread, keyword.operator.rest + settings + + fontStyle + bold + foreground + #f7768e + + + + name + Operator, Misc + scope + keyword.operator, keyword.control.as, keyword.other, keyword.operator.bitwise.shift, punctuation, punctuation.definition.constant.markdown, punctuation.definition.string, punctuation.support.type.property-name, text.html.vue-html meta.tag, punctuation.definition.keyword, punctuation.terminator.rule, punctuation.definition.entity, punctuation.definition.tag, punctuation.separator.inheritance.php, punctuation.definition.tag.html, keyword.other.template, keyword.other.substitution, entity.name.operator, text.html.vue meta.tag.block.any.html, text.html.vue meta.tag.inline.any.html, text.html.vue meta.tag.other.html, text.html.twig meta.tag.inline.any.html, text.html.twig meta.tag.block.any.html, text.html.twig meta.tag.structure.any.html, text.html.twig meta.tag.any.html + settings + + foreground + #89ddff + + + + name + Import, Export, From, Default + scope + keyword.control.import, keyword.control.export, keyword.control.from, keyword.control.default, meta.import keyword.other + settings + + foreground + #7dcfff + + + + name + Keyword + scope + keyword, keyword.control, keyword.other.important + settings + + foreground + #bb9af7 + + + + name + Keyword SQL + scope + keyword.other.DML + settings + + foreground + #7dcfff + + + + name + Keyword Operator Logical, Arrow, Ternary, Comparison + scope + keyword.operator.logical, storage.type.function, keyword.operator.bitwise, keyword.operator.ternary, keyword.operator.comparison, keyword.operator.relational, keyword.operator.or.regexp + settings + + foreground + #bb9af7 + + + + name + Tag + scope + entity.name.tag, entity.name.tag support.class.component, meta.tag + settings + + foreground + #f7768e + + + + name + Tag Punctuation + scope + punctuation.definition.tag, punctuation.definition.tag.html, punctuation.definition.tag.begin.html, punctuation.definition.tag.end.html + settings + + foreground + #ba3c97 + + + + name + Blade + scope + keyword.blade, entity.name.function.blade + settings + + foreground + #7aa2f7 + + + + name + PHP - Embedded Tag + scope + punctuation.section.embedded.begin.php, punctuation.section.embedded.end.php + settings + + foreground + #0db9d7 + + + + name + Smarty - Twig tag - Blade + scope + punctuation.definition.variable.smarty, punctuation.section.embedded.begin.smarty, punctuation.section.embedded.end.smarty, meta.tag.template.value.twig, punctuation.section.tag.twig, meta.tag.expression.twig, punctuation.definition.tag.expression.twig, punctuation.definition.tag.output.twig, variable.parameter.smarty + settings + + foreground + #7DCFFF + + + + name + Smarty - Twig variable - function + scope + variable.other.property.twig, support.function.twig, meta.function-call.twig, keyword.control.twig, keyword.control.smarty, keyword.operator.other.twig, keyword.operator.comparison.twig, support.function.functions.twig, support.function.functions.twig, keyword.operator.assignment.twig, support.function.filters.twig, support.function.built-in.smarty, keyword.operator.smarty, text.blade text.html.blade custom.compiler.blade.php punctuation.section.embedded.php entity.name.tag.block.any.html, text.blade text.html.blade custom.compiler.blade.php punctuation.section.embedded.php constant.other.inline-data.html, text.blade text.html.blade custom.compiler.blade.php support.function constant.other.inline-data.html + settings + + foreground + #2ac3de + + + + name + Globals - PHP Constants etc + scope + constant.other.php, variable.other.global.safer, variable.other.global.safer punctuation.definition.variable, variable.other.global, variable.other.global punctuation.definition.variable, constant.other + settings + + foreground + #e0af68 + + + + name + Variables + scope + variable, support.variable, string constant.other.placeholder + settings + + foreground + #c0caf5 + + + + name + Object Variable + scope + variable.other.object, support.module.node + settings + + foreground + #c0caf5 + + + + name + Object Key + scope + meta.object-literal.key, meta.group.braces.curly constant.other.object.key.js string.unquoted.label.js, string.alias.graphql, string.unquoted.graphql, string.unquoted.alias.graphql, meta.field.declaration.ts variable.object.property + settings + + foreground + #73daca + + + + name + Object Property + scope + variable.other.property, support.variable.property, support.variable.property.dom, meta.function-call variable.other.object.property, variable.language.prototype, meta.property.object, variable.other.member + settings + + foreground + #7dcfff + + + + name + Object Property + scope + variable.other.object.property + settings + + foreground + #c0caf5 + + + + name + Object Literal Member lvl 3 (Vue Prop Validation) + scope + meta.objectliteral meta.object.member meta.objectliteral meta.object.member meta.objectliteral meta.object.member meta.object-literal.key + settings + + foreground + #41a6b5 + + + + name + C-related Block Level Variables + scope + source.cpp meta.block variable.other + settings + + foreground + #f7768e + + + + name + Other Variable + scope + support.other.variable + settings + + foreground + #f7768e + + + + name + Methods + scope + meta.class-method.js entity.name.function.js, entity.name.method.js, variable.function.constructor, keyword.other.special-method, storage.type.cs + settings + + foreground + #7aa2f7 + + + + name + Function Definition + scope + entity.name.function, meta.function-call, meta.function-call entity.name.function, variable.function, meta.definition.method entity.name.function, meta.object-literal entity.name.function + settings + + foreground + #7aa2f7 + + + + name + Function Argument + scope + variable.parameter.function.language.special, variable.parameter, meta.function.parameters punctuation.definition.variable, meta.function.parameter variable + settings + + foreground + #e0af68 + + + + name + Constant, Tag Attribute + scope + keyword.other.type.php, storage.type.php, constant.character, constant.escape, keyword.other.unit + settings + + foreground + #bb9af7 + + + + name + Variable Definition + scope + meta.definition.variable variable.other.constant, meta.definition.variable variable.other.readwrite, variable.other.declaration + settings + + foreground + #bb9af7 + + + + name + Inherited Class + scope + entity.other.inherited-class + settings + + fontStyle + + foreground + #bb9af7 + + + + name + Class, Support, DOM, etc + scope + support.class, support.type, variable.other.readwrite.alias, support.orther.namespace.use.php, meta.use.php, support.other.namespace.php, support.type.sys-types, support.variable.dom, support.constant.math, support.type.object.module, support.constant.json, entity.name.namespace, meta.import.qualifier, entity.name.class + settings + + foreground + #0db9d7 + + + + name + Class Name + scope + entity.name + settings + + foreground + #c0caf5 + + + + name + Support Function + scope + support.function + settings + + foreground + #2ac3de + + + + name + CSS Class and Support + scope + source.css support.type.property-name, source.sass support.type.property-name, source.scss support.type.property-name, source.less support.type.property-name, source.stylus support.type.property-name, source.postcss support.type.property-name, support.type.property-name.css, support.type.vendored.property-name, support.type.map.key + settings + + foreground + #7aa2f7 + + + + name + CSS Font + scope + support.constant.font-name, meta.definition.variable + settings + + foreground + #9ece6a + + + + name + CSS Class + scope + entity.other.attribute-name.class, meta.at-rule.mixin.scss entity.name.function.scss + settings + + foreground + #9ece6a + + + + name + CSS ID + scope + entity.other.attribute-name.id + settings + + foreground + #fc7b7b + + + + name + CSS Tag + scope + entity.name.tag.css, entity.name.tag.reference, entity.name.tag.scss + settings + + foreground + #0db9d7 + + + + name + CSS Tag Reference + scope + entity.name.tag.reference + settings + + foreground + #e0af68 + + + + name + CSS Property Separator + scope + meta.property-list punctuation.separator.key-value + settings + + foreground + #9abdf5 + + + + name + CSS Punctuation + scope + meta.property-list, punctuation.definition.entity.css + settings + + foreground + #e0af68 + + + + name + SCSS @ + scope + meta.at-rule.mixin keyword.control.at-rule.mixin, meta.at-rule.include entity.name.function.scss, meta.at-rule.include keyword.control.at-rule.include + settings + + foreground + #bb9af7 + + + + name + SCSS Mixins, Extends, Include Keyword + scope + keyword.control.at-rule.include punctuation.definition.keyword, keyword.control.at-rule.mixin punctuation.definition.keyword, meta.at-rule.include keyword.control.at-rule.include, keyword.control.at-rule.extend punctuation.definition.keyword, meta.at-rule.extend keyword.control.at-rule.extend, entity.other.attribute-name.placeholder.css punctuation.definition.entity.css, meta.at-rule.media keyword.control.at-rule.media, meta.at-rule.mixin keyword.control.at-rule.mixin, meta.at-rule.function keyword.control.at-rule.function, keyword.control punctuation.definition.keyword, meta.at-rule.import.scss entity.other.attribute-name.placeholder.scss punctuation.definition.entity.scss, meta.at-rule.import.scss keyword.control.at-rule.import.scss + settings + + foreground + #9d7cd8 + + + + name + SCSS Include Mixin Argument + scope + meta.property-list meta.at-rule.include + settings + + foreground + #c0caf5 + + + + name + CSS value + scope + support.constant.property-value + settings + + foreground + #ff9e64 + + + + name + Sub-methods + scope + entity.name.module.js, variable.import.parameter.js, variable.other.class.js + settings + + foreground + #c0caf5 + + + + name + Language methods + scope + variable.language + settings + + foreground + #f7768e + + + + name + Variable punctuation + scope + variable.other punctuation.definition.variable + settings + + foreground + #c0caf5 + + + + name + Keyword this with Punctuation, ES7 Bind Operator + scope + source.js constant.other.object.key.js string.unquoted.label.js, variable.language.this punctuation.definition.variable, keyword.other.this + settings + + foreground + #f7768e + + + + name + HTML Attributes + scope + entity.other.attribute-name, text.html.basic entity.other.attribute-name.html, text.html.basic entity.other.attribute-name, text.blade entity.other.attribute-name.class, text.html.smarty entity.other.attribute-name.class + settings + + foreground + #bb9af7 + + + + name + Vue Template attributes + scope + meta.directive.vue punctuation.separator.key-value.html, meta.directive.vue entity.other.attribute-name.html + settings + + foreground + #bb9af7 + + + + name + Vue Template attribute separator + scope + meta.directive.vue punctuation.separator.key-value.html + settings + + foreground + #89ddff + + + + name + CSS IDs + scope + source.sass keyword.control + settings + + foreground + #7aa2f7 + + + + name + CSS pseudo selectors + scope + entity.other.attribute-name.pseudo-class, entity.other.attribute-name.pseudo-element, entity.other.attribute-name.placeholder, meta.property-list meta.property-value + settings + + foreground + #bb9af7 + + + + name + Inserted + scope + markup.inserted + settings + + foreground + #449dab + + + + name + Deleted + scope + markup.deleted + settings + + foreground + #914c54 + + + + name + Changed + scope + markup.changed + settings + + foreground + #6183bb + + + + name + Regular Expressions + scope + string.regexp + settings + + foreground + #b4f9f8 + + + + name + Regular Expressions - Punctuation + scope + punctuation.definition.group + settings + + foreground + #f7768e + + + + name + Regular Expressions - Character Class + scope + constant.other.character-class.regexp + settings + + foreground + #bb9af7 + + + + name + Regular Expressions - Character Class Set + scope + constant.other.character-class.set.regexp, punctuation.definition.character-class.regexp + settings + + foreground + #e0af68 + + + + name + Regular Expressions - Quantifier + scope + keyword.operator.quantifier.regexp + settings + + foreground + #89ddff + + + + name + Regular Expressions - Backslash + scope + constant.character.escape.backslash + settings + + foreground + #c0caf5 + + + + name + Escape Characters + scope + constant.character.escape + settings + + foreground + #89ddff + + + + name + Decorators + scope + tag.decorator.js entity.name.tag.js, tag.decorator.js punctuation.definition.tag.js + settings + + foreground + #7aa2f7 + + + + name + CSS Units + scope + keyword.other.unit + settings + + foreground + #f7768e + + + + name + JSON Key - Level 0 + scope + source.json meta.mapping.key.json string.quoted.double.json, source.json meta.structure.dictionary.json string.quoted.double.json + settings + + foreground + #7aa2f7 + + + + name + JSON Key - Level 1 + scope + source.json meta.mapping.value.json meta.sequence.json meta.mapping.key.json string.quoted.double.json, source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json string.quoted.double.json + settings + + foreground + #0db9d7 + + + + name + JSON Key - Level 2 + scope + source.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.mapping.key.json string.quoted.double.json, source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json string.quoted.double.json + settings + + foreground + #7dcfff + + + + name + JSON Key - Level 3 + scope + source.json meta.mapping.value.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.mapping.value.json meta.sequence.json meta.mapping.key.json string.quoted.double.json, source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json string.quoted.double.json, source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json string.quoted.double.json + settings + + foreground + #bb9af7 + + + + name + JSON Key - Level 4 + scope + source.json meta.mapping.value.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.key.json string.quoted.double.json, source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json string.quoted.double.json + settings + + foreground + #e0af68 + + + + name + JSON Key - Level 5 + scope + source.json meta.mapping.value.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.key.json string.quoted.double.json, source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json string.quoted.double.json + settings + + foreground + #0db9d7 + + + + name + JSON Key - Level 6 + scope + source.json meta.mapping.value.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.key.json string.quoted.double.json, source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json string.quoted.double.json + settings + + foreground + #73daca + + + + name + JSON Key - Level 7 + scope + source.json meta.mapping.value.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.key.json string.quoted.double.json + settings + + foreground + #f7768e + + + + name + JSON Key - Level 8 + scope + source.json meta.mapping.value.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.value.json meta.sequence.json meta.mapping.key.json string.quoted.double.json punctuation.definition.string.end.json + settings + + foreground + #9ece6a + + + + name + JSON Key - value + scope + source.json meta.structure.dictionary.json meta.structure.dictionary.value.json string.quoted.double.json, source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json string.quoted.double.json, source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json string.quoted.double.json, source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json string.quoted.double.json, source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json string.quoted.double.json, source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json string.quoted.double.json, source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.array.json meta.structure.dictionary.json meta.structure.dictionary.value.json string.quoted.double.json + settings + + foreground + #9ece6a + + + + name + Plain Punctuation + scope + punctuation.definition.list_item.markdown + settings + + foreground + #9abdf5 + + + + name + Block Punctuation + scope + meta.block, meta.brace, punctuation.definition.block, punctuation.definition.use, punctuation.definition.group.shell, punctuation.definition.class, punctuation.definition.begin.bracket, punctuation.definition.end.bracket, punctuation.definition.parameters, punctuation.definition.arguments, punctuation.definition.dictionary, punctuation.definition.array, punctuation.section + settings + + foreground + #9abdf5 + + + + name + Markdown - Plain + scope + meta.jsx.children, meta.embedded.block + settings + + foreground + #c0caf5 + + + + name + HTML text + scope + text.html + settings + + foreground + #9aa5ce + + + + name + Markdown - Markup Raw Inline + scope + text.html.markdown markup.inline.raw.markdown + settings + + foreground + #bb9af7 + + + + name + Markdown - Markup Raw Inline Punctuation + scope + text.html.markdown markup.inline.raw.markdown punctuation.definition.raw.markdown + settings + + foreground + #4E5579 + + + + name + Markdown - Heading 1 + scope + heading.1.markdown entity.name, heading.1.markdown punctuation.definition.heading.markdown + settings + + fontStyle + bold + foreground + #89ddff + + + + name + Markdown - Heading 2 + scope + heading.2.markdown entity.name, heading.2.markdown punctuation.definition.heading.markdown + settings + + fontStyle + bold + foreground + #61bdf2 + + + + name + Markdown - Heading 3 + scope + heading.3.markdown entity.name, heading.3.markdown punctuation.definition.heading.markdown + settings + + fontStyle + bold + foreground + #7aa2f7 + + + + name + Markdown - Heading 4 + scope + heading.4.markdown entity.name, heading.4.markdown punctuation.definition.heading.markdown + settings + + fontStyle + bold + foreground + #6d91de + + + + name + Markdown - Heading 5 + scope + heading.5.markdown entity.name, heading.5.markdown punctuation.definition.heading.markdown + settings + + fontStyle + bold + foreground + #9aa5ce + + + + name + Markdown - Heading 6 + scope + heading.6.markdown entity.name, heading.6.markdown punctuation.definition.heading.markdown + settings + + fontStyle + bold + foreground + #747ca1 + + + + name + Markup - Italic + scope + markup.italic, markup.italic punctuation + settings + + fontStyle + italic + foreground + #c0caf5 + + + + name + Markup - Bold + scope + markup.bold, markup.bold punctuation + settings + + fontStyle + bold + foreground + #c0caf5 + + + + name + Markup - Bold-Italic + scope + markup.bold markup.italic, markup.bold markup.italic punctuation + settings + + fontStyle + bold italic + foreground + #c0caf5 + + + + name + Markup - Underline + scope + markup.underline, markup.underline punctuation + settings + + fontStyle + underline + + + + name + Markdown - Blockquote + scope + markup.quote punctuation.definition.blockquote.markdown + settings + + foreground + #4e5579 + + + + name + Markup - Quote + scope + markup.quote + settings + + fontStyle + italic + + + + name + Markdown - Link + scope + string.other.link, markup.underline.link, constant.other.reference.link.markdown, string.other.link.description.title.markdown + settings + + foreground + #73daca + + + + name + Markdown - Fenced Code Block + scope + markup.fenced_code.block.markdown, markup.inline.raw.string.markdown, variable.language.fenced.markdown + settings + + foreground + #89ddff + + + + name + Markdown - Separator + scope + meta.separator + settings + + fontStyle + bold + foreground + #444b6a + + + + name + Markup - Table + scope + markup.table + settings + + foreground + #c0cefc + + + + name + Token - Info + scope + token.info-token + settings + + foreground + #0db9d7 + + + + name + Token - Warn + scope + token.warn-token + settings + + foreground + #ffdb69 + + + + name + Token - Error + scope + token.error-token + settings + + foreground + #db4b4b + + + + name + Token - Debug + scope + token.debug-token + settings + + foreground + #b267e6 + + + + name + Apache Tag + scope + entity.tag.apacheconf + settings + + foreground + #f7768e + + + + name + Preprocessor + scope + meta.preprocessor + settings + + foreground + #73daca + + + + name + ENV value + scope + source.env + settings + + foreground + #7aa2f7 + + + + uuid + 06f855e3-9fb7-4fb1-b790-aef06065f34e + + +