diff --git a/.gitignore b/.gitignore index 434ee8a..7a3f390 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .worktrees +.idea 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/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). 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 + + + 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/main.rs b/rust/src/main.rs index ff86ce0..18305d3 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); @@ -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; @@ -127,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 => {} } @@ -230,9 +219,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 +300,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); + } + } } 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/search.rs b/rust/src/search.rs index 03fe39d..a698fe4 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| { @@ -110,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, }); @@ -121,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::*; @@ -193,6 +228,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![ 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 8b54fd3..5d4b87f 100644 --- a/rust/src/ui.rs +++ b/rust/src/ui.rs @@ -1,45 +1,68 @@ use crate::app::App; use crate::preview::StyledPreview; -use ratatui::layout::{Constraint, Direction, Layout}; -use ratatui::style::{Color, Modifier, Style}; +use crate::theme; +use ratatui::layout::{Alignment, Constraint, Direction, Layout}; +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() .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(app.query.to_string()) + .style(pane_style()) + .block(pane_block("files")); + f.render_widget(query, left[0]); let items: Vec = app .results .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()), Span::raw(" "), - Span::styled(tag, Style::default().fg(Color::DarkGray)), + Span::styled(tag, Style::default().fg(theme::MUTED)), ])) }) .collect(); @@ -49,9 +72,15 @@ 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")) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - f.render_stateful_widget(list, mid[0], &mut state); + .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 .lines @@ -60,16 +89,67 @@ 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("preview")); + .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)); - f.render_widget(status, chunks[2]); + let status = + Paragraph::new(app.status.clone()).style(Style::default().bg(theme::BG).fg(theme::MUTED)); + 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(), + } +} + +/// 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::*; + 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"); + } + + #[test] + fn pad_title_adds_a_space_on_each_side() { + assert_eq!(pad_title("results"), " results "); + } } diff --git a/rust/tests/search_tests.rs b/rust/tests/search_tests.rs index 4f12eaf..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; @@ -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(); @@ -70,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"]); +}