bmrk 0.4.0

A fast TUI for directory navigation and bookmark management
# Architecture

Internal architecture of bmrk.

## Overview

MVC-style modular architecture. `app.rs` is the thin orchestrator; all logic lives
in specialized modules.

```
main.rs          CLI parsing (clap), terminal setup, path/bookmark resolution, entry-point routing
app.rs           Central state — holds all module instances, delegates everything
terminal.rs      Terminal lifecycle: setup, cleanup, panic hook, main event loop (8ms poll)
event_handler.rs All keyboard and mouse input — routes to correct module based on app mode
ui.rs            All rendering (ratatui layouts, widget composition, area tracking for mouse hits)
navigation.rs    Tree state: root node, flat visible list, selection, expand/collapse; also owns
                 the clipboard handle for the "copy path" action (`c`)
tree_node.rs     TreeNode data structure — Rc<RefCell<>> for zero-copy sharing
search.rs        Two-phase search: immediate visible-node scan + background thread for full tree
quick_jump.rs    Tab quick-jump: BFS prefix search, in-memory then background disk scan
dir_index.rs     Background-built directory index — a synchronous "Phase 1.5" accelerant consulted
                 by both quick_jump.rs and search.rs before their live disk scans
bookmarks.rs     Bookmark CRUD, persistence (JSON), interactive selection/creation/filter UI
config.rs        TOML config loading, color parsing, theme presets, auto-creates default config
disks.rs         Disk/volume information (via sysinfo)
platform.rs      Platform-specific path utilities (canonicalize, is_absolute, etc.)
theme/           Color theme structs and built-in presets
```

## Key Design Decisions

### stdout vs stderr

The TUI renders to **stderr**; path output goes to **stdout**. This is what makes the `bm`
wrapper work — it captures `$(bmrk "$@")` from stdout while the UI appears on screen.

### TreeNodeRef

```rust
pub type TreeNodeRef = Rc<RefCell<TreeNode>>;
```

The entire tree is shared references, not clones. The flat list in `Navigation` stores `Rc`
references into the same nodes, so expand/collapse and selection are O(1).

### Event Loop

8ms poll timeout in `terminal.rs`. On timeout (no input), `poll_search()`, `poll_quick_jump()`,
and `poll_dir_index_build()` each check their respective background thread/channel for
incremental results.

### Terminal Cleanup

`cleanup_terminal_compact()` performs a multi-stage process to disable all mouse tracking
modes, drain pending events, and restore terminal state. This is critical — do not simplify
it. Terminal artifacts (escape sequences leaking into shell) occur specifically with
resize + mouse interaction.

### Error Handling

All errors propagate via `anyhow::Result`. Never use `std::process::exit()` — it bypasses
terminal cleanup. Config and bookmark errors happen before terminal init so cleanup is not
needed; runtime errors after terminal init must still go through the explicit
`cleanup_terminal_compact()` call before the result is checked.

## Module Details

### `main.rs`

Entry point. Handles:
- CLI argument parsing with `clap`
- Early exits (`-h`, `-v`, `-l`, `-a`/`-c` (alias), `-d`)
- Bookmark/path resolution for the positional argument
- Terminal setup → `run_app()` → terminal cleanup → path output

### `app.rs`

Thin state container. Holds instances of `Navigation`, `Search`, `QuickJump`, `DirIndex`,
`Bookmarks`, `Config`, and `EventHandler`. Delegates `handle_key()`, `handle_mouse()`, `render()`,
`poll_search()`, `poll_quick_jump()`, `poll_dir_index_build()` to the appropriate modules.
`start_background_index()` kicks off the directory-index builder thread — called once from
`main.rs` after `App::new`, not from `App::new` itself, so constructing an `App` in tests never
races a real filesystem walk.

### `terminal.rs`

Terminal lifecycle:
- `setup_terminal()` — enable raw mode, mouse capture, install panic hook
- `run_app()` — 8ms event loop; dispatches to `app.handle_key/mouse`, calls `app.render()`
- `cleanup_terminal_compact()` — multi-stage cleanup (mouse disable, event drain, raw mode off)

### `event_handler.rs`

Processes all keyboard and mouse events. Routes based on current app mode:
1. Search input mode → search handling
2. Quick-jump input mode → quick-jump handling (arrow keys are the exception: they confirm the
   session and fall through to normal tree navigation in the same call)
3. Disk selection → disk navigation
4. Bookmark selection → bookmark navigation
5. Bookmark creation → name input
6. Escape / q check
7. Tree mode → navigation, including `c` (copy selected path to clipboard, via `Navigation`)

Mouse click and scroll events are dispatched to the active panel (disk, bookmark, or tree).
All panels use **minimal scrolling** — the view shifts only when the selection leaves the
visible area. Keyboard navigation sets `center_selection = true`; mouse actions set it to
`false`. This flag is present on `Navigation`, `Bookmarks`, and `Disks`.

### `ui.rs`

All rendering using `ratatui`. Calculates areas on each render and stores dimensions for
mouse hit testing. Renders: tree view, search results panel, bookmark panel, disk panel,
hint strings in the header row.

### `navigation.rs`

Manages the directory tree state:
- `root: TreeNodeRef` — current root node
- `flat_list: Vec<TreeNodeRef>` — visible nodes in display order
- `selected: usize` — cursor position
- `history: VecDeque<PathBuf>` — undo stack (up to 50 entries)
- `center_selection: bool` — scroll hint for the renderer: `true` centers the selection
  (keyboard navigation), `false` uses minimal scrolling (mouse actions)
- `nav_error: Option<String>` — last navigation error, displayed in the header until cleared
- `copy_feedback: Option<CopyFeedback>` — result of the last "copy path" action, displayed in
  the header until the next keypress
- `clipboard: Option<arboard::Clipboard>` (private) — created lazily on first copy and kept
  alive for the app's lifetime; on X11 an `arboard::Clipboard` hands its content off to a
  clipboard manager (if any) the moment it's *dropped*, so recreating one per keypress would
  lose the copied text before the user could paste it elsewhere

Key methods: `go_back()` (undo), `go_to_parent()`, `go_to_directory()`, `rebuild_flat_list()`,
`expand_path_to_node()` (used by quick jump and search-result selection),
`copy_selected_path_to_clipboard()`.

A new root created by `go_to_parent`/`go_back`/`go_to_directory`/`Navigation::new` is only marked
`is_expanded` when it actually has children (`has_children == Some(true)`) — never forced `true`
unconditionally. A childless root left `is_expanded = true` would be permanently stuck: the
`h` key collapses via `toggle_node`, which delegates to `TreeNode::toggle_expand`, which refuses
to touch `is_expanded` at all once `has_children == Some(false)` — so nothing could ever flip it
back to `false`, and `h`'s "go to parent" branch (gated on `is_expanded_dir == false`) would never
be reached, no matter how many times it was pressed.

**Quick-jump snapshot/restore**: `begin_quick_jump_snapshot()` (called when `Tab` activates)
captures the selection and every currently-expanded node path. `cancel_quick_jump()` (called on
`Esc`) collapses any node expanded since the snapshot and restores the original selection —
`Esc` fully cancels the session. `commit_quick_jump()` (called on a confirming `Tab`/`Enter`/
arrow key) just discards the snapshot, so the auto-expansion trail persists in that case.
`ensure_expanded(path, show_files)` expands `path` only if it isn't already (a no-op otherwise,
unlike `toggle_node` which would incorrectly collapse an already-open folder) — used by quick
jump's `/` narrowing to physically reveal the locked folder without disturbing a folder the user
had opened before `Tab`. `expanded_during_quick_jump_session(path)` reuses the same snapshot to
answer "did *this* session expand `path`?", which is how `Backspace`-undoing a `/` segment decides
whether it's safe to re-collapse the folder.

### `tree_node.rs`

```rust
pub struct TreeNode {
    pub path: PathBuf,
    pub name: String,
    pub is_dir: bool,
    pub is_expanded: bool,
    pub depth: usize,
    pub children: Vec<TreeNodeRef>,
    pub has_children: Option<bool>, // None = unknown, Some(false) = leaf (no "▶")
    pub has_error: bool,            // set by probe_has_children OR load_children on read failure
    pub error_message: Option<String>,
}

pub type TreeNodeRef = Rc<RefCell<TreeNode>>;
```

Directories are loaded lazily when first expanded. `probe_has_children` is called on each
child after the parent loads — it peeks inside the directory to determine whether a `▶`
expand arrow should be shown, and also sets `has_error` if `read_dir` fails. This means
inaccessible directories are marked with `⊘` (error color) as soon as the parent expands,
without requiring the user to attempt entry.

### `search.rs`

Two-phase search, plus a synchronous index pass in between:
- **Phase 1** (quick): searches already-loaded visible nodes — instant. Matches folder **and**
  file names (the tree itself stays directory-only, so a file result's `Enter`/`q` jumps to its
  containing folder instead)
- **Phase 1.5**: a synchronous pass over `DirIndex` for directory matches (see `dir_index.rs`
  below) — runs unconditionally after Phase 1, before Phase 2 is spawned
- **Phase 2** (deep): background thread walks the full tree and sends results via
  `crossbeam-channel`; UI polls with `poll_search()` in the timeout branch of the event loop.
  Still runs even when Phase 1.5 found directory matches, since it's the only source for file
  matches and for anything the index doesn't cover

Fuzzy mode activates when the query starts with `/` (uses `SkimMatcherV2`).
Directory and file results are capped independently (500 / 200) via a shared
`try_accept_result` helper, so a broad query's file matches can't crowd out directory matches.

### `quick_jump.rs`

`Tab` type-ahead jump, modeled on `search.rs`'s two-phase design but BFS-ordered (shallowest
match wins) and driven by every keystroke instead of `Enter`:
- **Phase 1** (`find_in_loaded_nodes`): synchronous BFS over already-loaded tree nodes
- **Phase 1.5** (in `event_handler.rs::resolve_quick_jump`): queries `DirIndex`, merged with
  Phase 1's results (deduplicated by path, sorted shallowest-first) rather than tried as a
  strict either/or — a session's own auto-expansion side effect can make Phase 1 start
  returning a shallow match on a later keystroke, which would otherwise silently hide a deeper
  match the index already knew about
- **Phase 2** (`deep_scan_bfs`): debounced (150ms after the last keystroke) background BFS disk
  walk, only spawned when the Phase 1 + Phase 1.5 merge is empty

Up to `MAX_MATCHES` (20) matches are kept per buffer for `Shift+Tab` cycling
(`QuickJump::cycle_next`). Every keystroke is always accepted into the buffer, even with no
match — there's no way to "reject" a keystroke synchronously once a debounced background scan
is in play.

**Narrowing (`/`)**: `QuickJump.scan_stack: Vec<PathBuf>` holds one entry per locked path
segment; the effective search root for Phase 1/1.5/2 is `scan_stack.last()`, falling back to
`nav.root`'s path when empty — `nav.root` itself is never touched, so `Esc`'s snapshot/restore
needs no special-casing for narrowed sessions. `push_segment(path)` (called from
`event_handler.rs` when `/` is pressed with a confirmed match) pushes `path` and appends `/` to
`buffer` instead of clearing it, so the bar grows into a path breadcrumb (`src` → `src/` →
`src/compon`). `pop_segment()` is a no-op unless `buffer` ends with `/` (i.e. nothing typed yet
in the new segment); otherwise it strips the trailing `/`, pops the stack, and returns the popped
path so `event_handler.rs` can re-collapse it (via `Navigation::expanded_during_quick_jump_session`
— only if quick jump itself expanded it) and move the selection back onto it. A free function,
`find_node_by_path(root, target)`, does a read-only descent through already-loaded `children` to
locate the `TreeNodeRef` for the current scan root, needed because Phase 1
(`find_in_loaded_nodes`) takes a node, not a path; a lookup miss just means Phase 1 contributes no
matches for that keystroke — Phase 2 only ever needs the `Path`, so it's unaffected.

### `dir_index.rs`

Background-built, persisted index of directory paths (`~/.config/bmrk/dir_index.txt`), rooted at
`index.roots` (the home directory by default). Consulted by both `quick_jump.rs` and `search.rs`
as a synchronous "Phase 1.5" step — it can only ever make a lookup faster or more complete,
never regress a case that worked before it existed, since both call sites fall through to their
original Phase 2 exactly as before whenever the index doesn't cover a match.

- `DirIndex::prefix_matches` — binary search into the name-sorted entry list for `Tab`
- `DirIndex::substring_or_fuzzy_matches` — linear scan for `/`
- Both filter by scope (`starts_with(root)`) and hidden-ness computed *relative to that root*,
  not the index's own root — this matters because the index may span a much larger subtree
  (e.g. all of `$HOME`) than the query's actual root
- Built once per app launch (if missing or older than `index.refresh_hours`) via
  `App::start_background_index()`, called from `main.rs`, not `App::new` (see `app.rs` above)
- Never follows symlinks (hardcoded); skips directory names in `index.ignore_dirs` entirely
  (not walked into)

### `bookmarks.rs`

Bookmark CRUD with JSON persistence. Manages two interactive modes:
- `is_selecting` — bookmark selection panel with navigation and filter sub-modes
- `is_creating` — name input with existing bookmarks shown for reference

`center_selection: bool` mirrors the same flag on `Navigation` — keyboard moves center the
view, mouse clicks and scroll use minimal scrolling. `filter_mode: bool` switches between
navigation (j/k) and text filter (type to narrow) within the selection panel.

### `config.rs`

Loads `config.toml`, auto-creates with defaults if missing. Parses:
- `AppearanceConfig`: `theme`, `icons`, `max_name_length`, `show_cursor_path`, `colors: ThemeConfig`
- `BehaviorConfig`: `show_hidden`, `follow_symlinks`, `double_click_timeout_ms`, `mouse_scroll_lines`
- `KeybindingsConfig`: `search`, `create_bookmark`, `select_bookmark`, `select_disk`,
  `go_to_parent`, `go_back`, `quit`, `exit`, `copy_path`
- `IndexConfig`: `enabled`, `refresh_hours`, `roots`, `ignore_dirs` — see `dir_index.rs` above

### `disks.rs`

Uses the `sysinfo` crate to enumerate all disk volumes with mount point, filesystem type,
free space, and total capacity. `center_selection: bool` controls scroll behavior for the
disk list panel — same semantics as `Navigation::center_selection`.

### `platform.rs`

Platform-specific path helpers: canonicalization, absolute path checks.

### `theme/`

Color theme structs and built-in presets (`default`, `gruvbox`, `nord`, `tokyonight`,
`dracula`, `obsidian`).

## Data Flow

### Startup

```
main()
  → Config::load()?          — parse config.toml (or create defaults)
  → [Handle -h/-v/-l/-a/-d]  — early exits
  → [Resolve bookmark/path]  — positional arg
  → App::new()               — init Navigation (loads dir_index.txt synchronously, if present),
                                Search, QuickJump, Bookmarks — no threads spawned here
  → app.start_background_index() — kicks off the directory-index builder thread, if stale
  → setup_terminal()?        — raw mode, mouse, panic hook
  → run_app()?               — event loop
  → cleanup_terminal()?      — always runs
  → [print selected path]
```

### Event Loop

```
loop {
  terminal.draw(|f| app.render(f))

  if event::poll(8ms) {
    Key(k)    → app.handle_key(k) → Some(path) | None = exit
    Mouse(m)  → app.handle_mouse(m)
    Resize    → consume (next draw recalculates layout)
  } else {
    app.poll_search()           // drain background search channel
    app.poll_quick_jump()       // debounced Phase-2 scan + result channel
    app.poll_dir_index_build()  // one-shot: swap in the finished index
  }
}
```

### Search Flow

```
User presses '/'
  → search.enter_mode()
User presses Enter
  → search.perform_search()
      → Phase 1:   scan loaded nodes (instant)
      → Phase 1.5: query DirIndex for directory matches (instant)
      → Phase 2:   spawn thread, walk tree, send via channel (files + anything Phase 1.5 missed)
Main loop (8ms timeout)
  → app.poll_search() → drain channel, update results, re-render
```

## Dependencies

| Crate               | Purpose                          |
|---------------------|----------------------------------|
| `ratatui 0.28`      | TUI framework                    |
| `crossterm 0.28`    | Terminal manipulation            |
| `anyhow 1.0`        | Error handling                   |
| `clap 4.5`          | CLI argument parsing             |
| `serde + serde_json`| Bookmark JSON persistence        |
| `toml 0.8`          | Config file parsing              |
| `dirs 5.0`          | Platform config/data directories |
| `crossbeam-channel` | Background thread communication  |
| `fuzzy-matcher 0.3` | Fuzzy search (SkimMatcherV2)     |
| `sysinfo 0.32`      | Disk enumeration                 |
| `unicode-width 0.1` | Unicode display width            |
| `libc 0.2`          | Low-level platform bindings      |
| `arboard 3.6` (`default-features = false`) | Clipboard text ("copy path", `c`) — image support disabled, unused |
| `time >= 0.3.47`    | Pinned to fix RUSTSEC-2026-0009  |