A high-level map of how herdr-file-viewer is built, for contributors and for anyone
reviewing the implementation. It's a small crate (~11k lines of Rust, inline tests included), so
this stays brief.
The viewer is a single process that draws both the directory tree (left) and the content pane (right) inside one ratatui frame. It is not composed of multiple herdr panes: herdr opens it as one split pane and the viewer owns the whole rectangle. This keeps focus, layout, and keyboard routing entirely in-process (no cross-pane IPC for the core UX), at the cost of drawing the two-column layout ourselves.
The crate is a library + a thin binary: src/main.rs dispatches on open_target::parse_args
(launch-decision modes print a decision for the shell scripts; otherwise lib::run(open_flag)).
Everything testable — including argv parsing — lives in the library modules.
Each module has one responsibility; the side-effecting ones sit behind traits so the controller is unit-testable with stubs.
| Module | Responsibility |
|---|---|
host |
The herdr boundary: parse the injected HERDR_PLUGIN_CONTEXT_JSON launch context, degrading to { cwd } on anything malformed (never panics). |
context |
The normalized LaunchContext the host hands to the resolver. |
root |
Resolve the tree root (git worktree top-level, else cwd) and git-presence; the re-root engine re-resolves the root and rebuilds the tree + git services in place when you switch worktrees. |
git |
Read-only git queries: status, baseline selection, changed-set, per-file diff. The only module that shells out to git, and only with read-only subcommands. |
herdr |
The herdr CLI seam ($HERDR_BIN_PATH): read-only queries (list git worktrees / which workspaces have an active agent) plus a best-effort host layout command (pane zoom --current --on/--off, the Z full-screen toggle). Neither touches file or git state; an absent or failing herdr degrades gracefully (git-only picker; in-pane zoom only). |
worktree |
Enumerate the repo's git worktrees (git worktree list --porcelain) and overlay herdr's agent-active workspace + per-row agent status, feeding the switch-worktree picker. |
tree |
The rooted, .gitignore-aware file tree: filters (gitignored, changed-only, hidden/dotfiles), cursor, expansion, status markers, and the ] / [ changed-file jump. Optionally folds a chain of single-child directories into one row (compact_dirs). A folded row has to look inside a collapsed directory, which the tree never opens otherwise, so foldability is answered by a two-entry probe rather than a listing and the answer is memoized — re-probed wherever the controller re-reads git. Listings stay uncached, so a compacted frame reads exactly the directories an uncompacted one does. |
view_policy |
A pure decision: which view mode a file gets (deleted → diff; other changed files → configured diff or normal-file preference; markdown → rendered; else → syntax content) and the cycle order. |
preview |
Shared active/pinned preview values: an immutable applied document with captured origin identity, plus separate mutable interaction state (viewport, scroll, search, and paging) for each displayed preview. |
preview_layout |
Pure responsive geometry for the tree, active preview, and pinned preview. It keeps the preview divider distinct from the tree divider, applies the 20–80% pinned share from the right edge, and signals when a pin misses its 40-column floor so the no-pin tree/active geometry remains intact. |
focus_policy |
Pure focus cycle and action-target policy. With a visible pin it routes Tab through tree → active → pinned, skips an undrawn pin, gives pinned in-file navigation its own target, and rejects live-selection actions from the frozen reference. |
render |
Produce the content-pane text: classify the file, delegate styling to an external CLI, and neutralize escape sequences before display. |
presenter |
Draw the two-column (or zoomed / narrow) layout with ratatui, including persistent annotation markers and background-only styling; source-line backgrounds are applied beneath active line-select, ambient-selection, and search overlays, with a bounded one-cell cue for blank annotated lines. Draw active and pinned preview projections through the same rendering path, surface the persistent hidden-pin widen notice on active, and report their independent viewport, scrollbar, and divider geometry back for hit-testing. |
picker |
The modal worktree-switcher overlay state (rows, cursor, horizontal scroll) drawn over the layout; captures its own nav / confirm / cancel keys while open. |
proc |
Shared subprocess reaping: one wait_bounded (child wait + poll + timeout-kill) used by both the content renderer and the update check, so the timeout-kill semantics are defined once. |
finder |
The modal go-to-file finder overlay state (query, ranked matches, cursor, scroll) drawn over the layout; captures its own keys while open and navigates the tree selection on confirm. |
fuzzy |
A pure fuzzy matcher: rank file paths against a typed query (the finder's scoring), no I/O. |
index |
Build the flat, .gitignore-aware list of repo file paths the finder searches. |
search |
A pure in-file substring matcher: find every occurrence of a query within the displayed content's lines (smartcase, literal, never a regex), returning byte-offset match ranges in document order. No I/O. |
highlight |
Overlay match highlighting onto the content pane: re-segment each line's spans at the match byte boundaries and patch a highlight style over the matched runs, with a distinct style on the current match. Pure; composes over the delegated render rather than re-rendering. |
text_layout |
A pure text-wrapping helper: how many display rows a line occupies at a given width, shared by the content pane, the finder, and the help overlay. No I/O. |
prompt |
The reusable Unicode-safe, cursor-aware single-line editor (initial text, insert/delete, backspace, Left/Right/Home/End) shared by finder/search appenders and the annotation editor. |
infile |
In-file-navigation modal state: which bottom prompt is open (go-to-line or in-file search), its prompt input buffer, the live SearchState (query, matches, current match), and the content-scroll snapshot for cancel-restore. |
lineselect |
Line-select modal state: anchor + marker source-line indices (plus mouse char carets), the focus-gated L entry that auto-switches to the source view, key/mouse handling, and the confirms — formatting the path:line / path:start-end reference (Enter), extracting selected text (y/Y), or snapshotting the covering line range for an annotation (a). Read-only. |
annotation |
Typed, session-only annotation domain state: monotonic IDs, normalized 1-based line ranges, immutable root-relative targets, normalized notes, deterministic store ordering, and the exact escaped <file-annotations> clipboard serializer (-> separates reference from note, so a note containing a colon cannot reproduce the separator). Pure; no I/O. |
help |
Help overlay state: the embedded changelog source and About text, plus the section and vertical scroll position for the ? overlay; also formats the display-only Settings section (settings_text) showing the config's effective values and load outcome, and the display-only Keybindings section (keybindings_text) listing every action's effective key(s) and description, marking customized bindings and surfacing any ignored [keys] entries (view-only, never an editor for the config file). Pure; no I/O: the changelog is compiled in at build time. |
input |
The keybinding registry (the single source of truth for each global action's intent name, default key(s), and description), the key-spec parser (a bindable-key whitelist, no Ctrl/Alt), the bindings resolver (layers a user's [keys] config over the registry into the effective key → intent map, precedence config > default, with an Esc-always-closes floor), and the pure key → intent dispatcher that decodes crossterm events against those effective bindings. |
intent |
The closed set of user intents (one exhaustive enum). |
controller |
Orchestrate intents → state changes; hold ephemeral session state, including the root-bound AnnotationStore and one frozen pinned preview; dispatch renders to the worker; map mouse events against fed-back geometry; and rebuild root-bound services on a worktree switch. A pin retains its captured origin and independent interaction state across re-root; it is removed only by unpin/replacement or session end. Its owned annotation projection root-joins file targets, follows the applied content_path rather than the live cursor, and exposes merged line ranges only when the applied render carries a source map. Feature submodules are mod, mouse, help, finder, picker, infile, lineselect, annotation, pinned, and git_apply. One Modal enum type-enforces exclusive input ownership. Only a successful root change clears annotations; failure and same-root paths preserve them. Quitting or switching worktree with a non-empty store raises the Modal::DiscardConfirm layer (for a quit, outside search/unzoom) rather than silently discarding it; its y proceeds only on a successful clipboard write, and a switch re-validates its held target before committing. |
app |
The event loop (run()): assemble the live components, then draw → poll input → route to the controller (or the active modal) → drain finished renders, until the user closes the viewer. |
update |
Fixed official HTTPS sources, bounded display-only remote notices through a fail-silent Official Repository Gateway; a 15-second refresh uses optional system curl documents (1 MiB each), then atomically publishes a complete, safe-to-delete advisory cache. update_check is the sole config setting for both notice types, with HERDR_FILE_VIEWER_NO_UPDATE_CHECK as an environment fallback; 404 withdraws a spotlight. |
config |
Load & resolve the read-only TOML config: path resolution ($HERDR_PLUGIN_CONFIG_DIR, else XDG fallback), defensive parse (malformed input degrades to defaults, never panics), and precedence (config > env > default) → the EffectiveSettings consumed at startup by controller, editor, render, opener, and update; it also parses the [keys] remapping table into KeySpec (string-or-array) entries the input bindings resolver layers over the registry. Never writes the file. |
editor |
Hand a file off to $EDITOR, or the config's editor override (launch only — never reads or writes the file). |
opener |
Read-only OS hand-off for the O / R keys: a pure per-OS argv builder (open-with-default-app / reveal-in-file-manager, overridable via the config's open / reveal keys) plus an Opener seam over the reused editor Spawner, spawned non-blocking (no terminal takeover, stdio nulled) so the TUI keeps running. |
launch |
The "launch-or-focus-or-toggle" decision behind the shell launch scripts (pure, hermetically testable). |
open_target |
Pure argv parse (parse_args), open-target parse/resolve (path / path:line from CLI --open or HERDR_FILE_VIEWER_OPEN, lexically normalized under the root), and helpers; the controller applies a target once at startup via reveal + optional pending go-to-line. |
herdr → env (HERDR_PLUGIN_CONTEXT_JSON, optional HERDR_FILE_VIEWER_OPEN)
│
host::from_env → root::resolve → git::default_baseline
│
Controller::new ── wires live GitService / ContentProvider / EditorHandoff / Clipboard behind traits
│
optional open target (CLI --open > HERDR_FILE_VIEWER_OPEN) → reveal + render [+ pending go-to-line]
│
event loop (app::run): draw → poll input → handle(intent) → drain finished renders → repeat
Rendering is off the input thread. Selecting a file dispatches a render job to a worker
thread (std::thread + mpsc); handle() returns immediately so input never blocks on a slow
external renderer. The finished text arrives later and is drained by Controller::poll() each
tick. Jobs carry a monotonic sequence so a stale render for a file the user has left is dropped.
A renderer panic is contained (catch_unwind) so the worker survives. No tokio.
Annotation indicators are a Controller-owned, borrow-free ViewState projection, not fields on tree
nodes. File markers include collapsed targets. The displayed-file marker follows the applied content
path; numeric targets become line backgrounds only when that same applied render supplies
RenderResult::source. Rendered Markdown, diffs, placeholders, and in-flight selections therefore
retain file/title markers where applicable but never receive guessed source-line styling.
- Read-only. The viewer never mutates a file or the git repository. Annotation add/edit/delete
operations change only in-memory session state, and copy uses the same OSC 52 clipboard seam as
path/line copy. The editor path is a hand-off to an external process. Every
gitinvocation uses read-only subcommands. - Delegate rendering. Markdown, diffs, and syntax highlighting are produced by best-in-class
external CLIs (
glow,delta,bat): the viewer builds only the shell and ingests their ANSI output. Each renderer is optional; a missing one degrades to plain text + a notice. - Git is first-class, woven through the tree (status markers, colors, changed-only filter, baseline toggle) and the content pane (diff view), not a separate mode.
- In-memory, ephemeral state only, including annotations, which start empty and are scoped to
the current root. A successful re-root clears them; failed and same-root attempts do not. The
safe-to-delete
update-check.jsonadvisory cache is the sole persistent exception; it never changes the viewed root or git repo.
Four untrusted inputs are handled defensively (see SECURITY.md):
- File content is untrusted: fed to renderers on stdin (never as an argument), and the renderer output is re-sanitized so no escape sequence can drive the terminal.
- The git repository may be untrusted (an agent's worktree, a clone): every
gitinvocation is hardened against repo-controlled code execution (no external diff/textconv, neutralizedcore.fsmonitor/core.hooksPath, scrubbed repo-redirecting env). This hardening lives in one shared builder so it cannot drift between callers. - The herdr-injected context is parsed defensively and degrades to a minimal default.
- Official remote-notice documents come only from fixed HTTPS sources, stay bounded and fail-silent, and pass through terminal-control neutralization before display.
cargo test runs unit tests, integration tests, and end-to-end tests that drive the real binary
over a pseudo-terminal (expectrl), plus ratatui TestBackend snapshot tests (insta). The e2e
tests stub the editor via $EDITOR and run in temp directories, so they need neither the
external renderers nor a live herdr.