diff --git a/.gitignore b/.gitignore index d0f63ea1..a312c320 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ # Review-gate run logs / dismissal records: local audit trail, not part of the plugin .review-gate/ +# pi-empanel run records: local review artifacts, not part of the plugin +.empanel/ + # Private, per-working-copy agent overlay: never committed (see AGENTS.md routing guideline) AGENTS.local.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3dd9a4c6..c9c560ed 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -31,8 +31,11 @@ is unit-testable with stubs. | `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 (changed → diff, 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. Scroll the tree/content and report viewport + pane geometry back for hit-testing. | +| `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. | @@ -48,7 +51,7 @@ is unit-testable with stubs. | `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`; dispatch renders to the worker; map mouse events against fed-back geometry; and rebuild root-bound services on a worktree switch. 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`, 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. | +| `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 `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. | diff --git a/CHANGELOG.md b/CHANGELOG.md index 624715d0..e247a2a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +### Added +- Pinned preview: `p` freezes one settled file preview beside the active file for session-only comparison. Active stays beside the tree and pin sits on the right; when space is short the active/tree layout remains intact, the held pin says how to widen it, and `Tab` visits only visible regions. Its title carries the captured branch, naming the worktree only when the pin came from a different one, finder confirmation takes focus to the chosen active file, and branch-change replacement names both branches. The captured origin survives a worktree switch; each preview keeps independent scroll/search state, and `{` / `}` or the mouse resize their divider. → [usage](docs/usage.md#pinned-previews) · [keys](docs/keys.md) + ### Fixed - Agent skill: the launch instructions no longer tell agents to pass `--cwd`. herdr resolves the manifest's relative pane command against it, so the launch failed with `plugin_pane_open_failed` — or worse, inside a built plugin checkout, silently ran that checkout's binary. The skill and the `docs/usage.md` snippet now explain that the viewed root follows the *focused herdr pane's* directory, so an agent's own `cd` does not move it. Thanks @AntonyKor (#139) → [agent skill](skills/herdr-file-viewer/SKILL.md) · [usage](docs/usage.md#teach-your-agent) diff --git a/CONTEXT.md b/CONTEXT.md index f9463bca..2addf216 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -9,7 +9,20 @@ Canonical vocabulary for this repo. Glossary only: no implementation detail, no - **tree**: the left column: a recursive, expandable directory tree of the current root, decorated with git status markers. - **content pane**: the right column: shows the selected file as rendered markdown, - a diff, or syntax-highlighted content, depending on the active view mode. + a diff, or syntax-highlighted content, depending on the active view mode. Becomes the + **active preview** when a **pinned snapshot** is present. +- **active preview**: the content region that follows the current **tree** selection. When a + **pinned snapshot** exists and width permits, it shares the content area with that snapshot. +- **pinned snapshot**: one session-only, frozen copy of a file's displayed preview, including its + **pinned origin identity**. It does not follow later file changes or refreshes, survives a + **worktree switch**, and remains until replaced, explicitly unpinned, or the viewer exits. +- **pinned origin identity**: the exact `(root/worktree, branch state, root-relative path)` tuple + captured with a **pinned snapshot**; branch state is either the captured branch name or detached. +- **split-preview layout**: the comparison state that presents the **tree**, one **pinned snapshot**, + and the **active preview** as adjacent columns when width permits; the two previews scroll + independently. +- **preview divider**: the adjustable boundary between the **pinned snapshot** and **active preview** + in the **split-preview layout**; distinct from the tree/content divider. - **view mode**: which rendering the content pane is showing (rendered markdown / diff / content). Auto-selected per file, cyclable by the user. - **diff baseline**: what a diff (and the meaning of "changed") is compared against: diff --git a/config.example.toml b/config.example.toml index 36f7f748..550b27bd 100644 --- a/config.example.toml +++ b/config.example.toml @@ -156,10 +156,10 @@ # Keybindings tab (e.g. refresh, nav_up, switch_worktree). A value is a KEY SPEC: # a single string, or an array of strings. An entry REPLACES that action's # default key(s), so list every key you want it to answer to. The full list of -# intent names (all 39 actions) is in docs/configuration.md (Keybindings) and the +# intent names (all 44 actions) is in docs/configuration.md (Keybindings) and the # `?` overlay's Keybindings tab. # -# Bindable keys: any single printable or shifted character (`g`, `<`, `?`, and +# Bindable keys: any single printable or shifted character (`g`, `<`, `{`, `?`, and # capitals such as `W` are each their own key), plus the named keys Tab, Enter, # Esc, the four arrows, Home, End, PageUp, PageDown, Space, Backspace, Delete, # Insert, and F1 through F12 (named keys are case-insensitive). There are NO diff --git a/docs/configuration.md b/docs/configuration.md index aa22d1de..bcd16b59 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -185,8 +185,11 @@ customized). | | `toggle_focus` | `Tab` | Move focus between the tree and content columns | | | `shrink_tree` | `<` | Narrow the tree column | | | `grow_tree` | `>` | Widen the tree column | +| | `shrink_preview` | `{` | Give the pinned preview less horizontal space | +| | `grow_preview` | `}` | Give the pinned preview more horizontal space | | | `toggle_wrap` | `w` | Force content-line wrapping on or off | | | `toggle_zoom` | `z` | Hide the tree so content fills the frame, or restore the split | +| | `pin_preview` | `p` | Pin or unpin the settled preview as a reference | | | `tree_scroll_left` | `H` | Scroll the tree pane left | | | `tree_scroll_right` | `L` | Scroll the tree pane right | | **Git & filters** | `toggle_ignore` | `i` | Reveal or hide gitignored files | @@ -222,7 +225,7 @@ and annotation-overview `j`/`k`/arrows, `Enter`/`e`, `d`, uppercase `D`, `y`, `E the finder and `:` / `/` prompts. Remapping a global action never changes these local modal keys. **Bindable keys** are the modifier-free surface the viewer already uses: any printable or shifted -character (`g`, `<`, `?`, and capitals such as `A`, `D`, and `W` are each their own key), plus the named keys +character (`g`, `<`, `{`, `?`, and capitals such as `A`, `D`, and `W` are each their own key), plus the named keys `Tab`, `Enter`, `Esc`, the four arrows, `Home`, `End`, `PageUp`, `PageDown`, `Space`, `Backspace`, `Delete`, `Insert`, and `F1` through `F12` (named keys are matched case-insensitively). There are **no `Ctrl` / `Alt` chords**: a chord never fires a viewer action, so terminal combinations like @@ -243,6 +246,12 @@ rebound away, so you can never strand yourself (you may still move the `q` Close action). Only the global keys are remappable; keys handled inside a modal (including line-select and the annotation editor/overview) keep their fixed keys. +Shifted characters are distinct bindings, so `p`, `{`, and `}` are valid values for `pin_preview`, +`shrink_preview`, and `grow_preview` respectively; the latter two resize the pinned preview's share +at the preview divider. On Windows, AltGr typing continues to reach whichever character binding you +configure (including a shifted character), rather than being treated as a `Ctrl`/`Alt` shortcut; see +the [AltGr note](keys.md#keys) for the platform-specific input details. + See your bindings in effect any time in the `?` help overlay's **Keybindings** section. It groups the actions into sections and shows, for each, its config-var name (the `[keys]` id you type to remap it), its effective key(s), and its description, marking the ones you have customized. The full diff --git a/docs/keys.md b/docs/keys.md index a80548f8..063b36b4 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -29,7 +29,7 @@ is additive and on by default. | `e` | Open the selected file in `$EDITOR` (see [Opening in an editor](#opening-in-an-editor)) | | `O` (Shift+`o`) | **Open with default app**: hand the selected file or directory to the OS default application (e.g. an image opens in the system viewer). Read-only hand-off; non-blocking (the viewer keeps running) | | `R` (Shift+`r`) | **Reveal in file manager**: open the OS file manager (Finder / Explorer / a Linux file manager) with the selected entry highlighted where supported, so you can drag it out (e.g. into Slack). Read-only hand-off | -| `f` | **Go to file**: open a fuzzy finder over every file in the tree; type to filter, `↑` / `↓` move, `Enter` opens the selected file, `Esc` cancels (`←` / `→` scroll long paths) | +| `f` | **Go to file**: open a fuzzy finder over every file in the tree; type to filter, `↑` / `↓` move, `Enter` opens the selected file and focuses its active preview, `Esc` cancels (`←` / `→` scroll long paths) | | `:` | **Go to line**: open a prompt and jump the content pane to a source line by number (`Enter` jumps, `Esc` cancels; out-of-range clamps to the last line). Works in any view; in a rendered-markdown or diff view, confirming switches to the line-numbered content view and jumps there | | `/` | **Search in file**: open a prompt and highlight every match in the content pane as you type; `Enter` commits the search (highlights persist), `Esc` clears it and restores the scroll. Smartcase (a lowercase query is case-insensitive; a capital makes it case-sensitive). Works in any view | | `n` / `N` (Shift+`n`) | After a committed search, jump to the **next** / **previous** match and scroll it into view, wrapping at the ends with a notice | @@ -38,10 +38,12 @@ is additive and on by default. | `Y` | Copy the selected file's **absolute** path to the clipboard | | `a` | **Add annotation**: open the annotation editor for the selected file (`←`/`→` or `Home`/`End` move the text cursor, `Enter` saves, `Esc` cancels). Annotations live only for this viewer session and never modify the file | | `A` (Shift+`a`) | **Show annotations**: open the session overview; fixed keys `j`/`k` or `↑`/`↓` move, `Enter`/`e` edits, `d` deletes one, `D` (Shift+`d`) clears all immediately, `y` copies all, and `Esc`/`q` closes | -| `Tab` | Move focus between the tree and content columns | -| `<` / `>` | Narrow / widen the tree column (move the divider) | +| `Tab` | Move focus between visible regions. With a pin: tree → active preview → pinned preview → tree; when the pin is hidden for space, tree → active preview | +| `<` / `>` | Narrow / widen the outer tree column (move the tree/content divider) | | `w` | Toggle line wrapping for the content pane. For rendered markdown this switches between the fit-to-pane view (wide tables sized to fit, over-long cells shown as `…`) and a wide view that renders tables at full width and scrolls horizontally (`←`/`→`) so you can read every cell | | `z` | Zoom: hide the tree so the content pane fills the frame; press again (or `q`/`Esc`) to restore the two-column layout | +| `p` | Pin the settled file preview as a frozen in-memory reference alongside the active preview; press again on that same file to remove it, or on another file to replace it. `Tab` then cycles tree → active preview → pinned preview → tree; if there is not room, the pin persists and the active preview explains how to widen it | +| `{` / `}` | With a preview pinned, shrink / grow its horizontal share in 5-point steps (20–80%) by moving the **preview divider**; the outer tree/content divider stays put. Inert with no pin | | `Z` (Shift+`z`) | **Full-screen a file** (toggle): open the selected file like `Enter` _and_ zoom the viewer's herdr pane to fill the whole terminal, so the file takes over the entire screen instead of just the split. Press `Z` again (or `Esc`/`q`, or `z`) to return to the normal two-column split; switching worktree or quitting also restores the pane. On a directory it just expands/collapses like `Enter`; falls back to the in-pane zoom when the host isn't herdr | | `r` | Refresh git state: pick up changes made outside the viewer (a merge / pull / commit elsewhere) | | `W` (Shift+`w`) | **Switch worktree**: open a picker of the repo's git worktrees and re-root the viewer to the one you pick (read-only; no branch checkout). Marks the current worktree and pre-selects the one with an active herdr agent; `↑`/`↓` move, `←`/`→` scroll long paths, `Enter` switches, `Esc` cancels. A switch clears annotations (their targets belong to the old root), so with any held it confirms first (`y` copies them and switches, `Enter` switches and discards, `Esc` cancels) | @@ -68,7 +70,7 @@ forces a full refresh on demand. (Focus-refresh updates the tree's status withou content scroll.) Character keys with a control modifier are normally inert, so terminal chords such as `Ctrl+C` do -not trigger a viewer action; `Shift` is permitted for keys such as `<` and `>` (and `a`/`A`, +not trigger a viewer action; `Shift` is permitted for keys such as `<`, `>`, `{`, and `}` (and `a`/`A`, `y`/`Y`, `W`, `N`, `O`, `R`, `Z`, `?`, `H`/`L`, `J`/`K` in line-select mode, and `d`/`D` in the annotation overview). @@ -77,8 +79,8 @@ as a chord. Crossterm 0.29's Windows input path reports AltGr as the generic `Ct so a genuine Windows `Ctrl+Alt`+character chord is ambiguous with AltGr typing a bound character: pressing AltGr+`?` on a Brazilian ABNT2 keyboard still opens (or closes) the help overlay, and the same applies to whatever character any of your bindings use, including a custom `[keys]` remap. -(Some layouts produce other characters via AltGr too, e.g. `[`/`]` on German and Spanish ones; those -are unbound by default, so they trigger nothing unless you bind them yourself.) `Ctrl` alone, `Alt` +(Some layouts produce other characters via AltGr too, e.g. `[`/`]` or `{`/`}` on German and Spanish ones; +`[`/`]` keep their changed-file movement and `{`/`}` their pinned-preview resizing.) `Ctrl` alone, `Alt` alone, `Ctrl+Alt` on a non-character key (`Ctrl+Alt+↑`), and `Ctrl+Alt` plus any modifier other than optional `Shift` stay inert. Linux and macOS decoding is unchanged. To prevent a particular Windows `Ctrl+Alt` chord from triggering its viewer action, rebind that action off the character in diff --git a/docs/usage.md b/docs/usage.md index 1015d7f1..d06d5e87 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -8,6 +8,7 @@ customize it see [configuration](configuration.md). - [Finding a file fast](#finding-a-file-fast) - [Open at a known file](#open-at-a-known-file) (incl. [Teach your agent](#teach-your-agent)) - [Viewing a file](#viewing-a-file) +- [Pinned previews](#pinned-previews) - [Git awareness](#git-awareness) - [Navigating within a file](#navigating-within-a-file) - [Annotating files and ranges](#annotating-files-and-ranges) @@ -46,7 +47,8 @@ that one, and the chain stops the moment a directory holds a file or a second en Press `f` to open a **fuzzy finder** over every file in the tree (`.gitignore`-aware). Type to filter, `↑`/`↓` to move, `Enter` to open, `Esc` to cancel — far faster than scrolling the tree in a -large repo. +large repo. Confirming from a pinned preview moves focus to the active preview where the chosen +file opens. ## Open at a known file @@ -187,6 +189,43 @@ Rendering is **delegated** to `glow` (markdown), `delta` (diffs), and `bat` (syn renderer isn't installed the viewer falls back to plain text with a short notice. See [external renderers](renderers.md). +## Pinned previews + +Press `p` on a settled file preview to keep a **frozen in-memory snapshot** beside the file you +continue browsing. The snapshot is session-only: it does not reread the file, change after `r`, or +follow later renders. Press `p` again on the same file to unpin it; press it on a different settled +file to replace the reference. A directory, an empty tree, or a preview still rendering cannot be +pinned. + +Every pin carries its **captured origin** — the branch, or detached state, it was taken on — in its +title, as `Pinned: [main]`. The captured path is not in the title; it would clip the origin away on +a narrow pane, and `y`/`Y` copy it anyway. When the pin comes from a **different worktree** than the +one you are viewing, the title names that worktree too, as `Pinned: [main @ other-checkout]`, so a +foreign reference can never be mistaken for a local one. A pin from the worktree you are already in +names no worktree, since repeating it buys nothing. It therefore **survives a worktree switch** and +remains useful even if its old worktree is no longer selected: after such a switch the same pin +starts naming the worktree it came from. While the pin is focused, `y` copies its **captured repo-relative path** and +`Y` its **captured absolute path**; neither operation reads the current tree or the old file again. + +With the tree visible, `Tab` cycles focus **tree → active preview → pinned preview → tree**. In +tree-hidden zoom the cycle is active preview then pinned preview. The pinned and active previews +have separate scroll positions and searches are independent: arrows, paging, `/`, and `n`/`N` +operate only on the focused preview. On a narrow pane, pinning never takes a pane away: the tree +and active preview retain the no-pin layout, the hidden pin persists, and the active preview says +`Pinned: — widen to view` until there is room for both **40-column floor** previews. `Tab` +then visits only the visible tree and active preview. + +The reference is display-only. Actions that need the live selection — including `Enter`, `:`, `e`, +`L`, `O`, `R`, `a`, `A`, `v`, `D`, `w`, and `Z` — are **unavailable from the pinned preview** and +show a short notice; `Tab` to the active preview or tree to use them. This keeps a frozen reference +from silently acting on a newer tree selection. + +Use `{` / `}` to shrink or grow the pin in 5-point steps (20–80%) without moving the tree/content +split. They move the **preview divider** between the pinned and active previews and do nothing when +there is no pin. You can also **drag the preview divider** with the mouse; it is separate from the +outer tree/content divider (`<` / `>`). Each preview has its own scrollbars, which you can drag or +press just as you would the active content scrollbar. + ## Git awareness Git status is woven straight into the tree, not a separate mode: diff --git a/src/app.rs b/src/app.rs index 074a3cc4..61b377b3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -248,12 +248,12 @@ fn event_loop(terminal: &mut DefaultTerminal, controller: &mut Controller) -> io terminal.draw(|frame| { controller.set_width(frame.area().width); let view: ViewState = controller.view_state(); - let (cw, ch) = presenter::draw(frame, &view); + let viewports = presenter::draw(frame, &view); // Feed the drawn content viewport back so content scrolling can be clamped to // it on the next intent, and the hit-test geometry so a mouse event maps to the // live layout. `true` means a deferred launch-open zoom just armed (narrow // tree-only pane) and we must paint again so the file is actually visible. - need_redraw = controller.set_content_viewport(cw, ch); + need_redraw = controller.set_preview_viewports(viewports); controller.set_pane_geometry(presenter::geometry(frame.area(), &view)); })?; dirty = need_redraw; diff --git a/src/controller/annotation.rs b/src/controller/annotation.rs index a4511edf..f5e5dcfc 100644 --- a/src/controller/annotation.rs +++ b/src/controller/annotation.rs @@ -133,10 +133,11 @@ impl Controller { /// Owned persistent-indicator projection for the pure Presenter. pub(super) fn annotation_indicators_view(&self) -> AnnotationIndicatorsView { let displayed_relative = self - .content_path - .as_deref() + .active_display + .displayed_origin() + .map(PreviewOrigin::absolute_path) .and_then(|path| path.strip_prefix(&self.root).ok()); - let source_mapped = self.content_source.is_some(); + let source_mapped = self.active_source().is_some(); let mut view = AnnotationIndicatorsView::default(); let mut displayed_ranges = Vec::new(); diff --git a/src/controller/finder.rs b/src/controller/finder.rs index 9c5109d1..1ca6361d 100644 --- a/src/controller/finder.rs +++ b/src/controller/finder.rs @@ -283,10 +283,16 @@ impl Controller { // tree row with the file hidden off-screen. This mirrors the tree's Enter/activate on a // file (content full-screen). When the content is already visible (the wide two-column // layout, or already zoomed), the layout is left untouched and the file just renders. - if self.content_width == 0 { + if self.active_interaction.viewport_width == 0 { self.zoomed = true; self.focus = Focus::Content; } + // A finder confirmation means "take me to this file", unlike changed-file jumps + // which intentionally keep pinned focus for comparison (AC-31). Only pinned focus + // moves: a confirm from the tree leaves focus on the tree exactly as before (AC-41). + if self.focus == Focus::Pinned { + self.focus = Focus::Content; + } self.dispatch_render(); Effects::redraw() } else { diff --git a/src/controller/infile.rs b/src/controller/infile.rs index cb01ce2b..d95b68ad 100644 --- a/src/controller/infile.rs +++ b/src/controller/infile.rs @@ -4,6 +4,73 @@ use super::*; +/// Extract the plain text of displayed lines for an in-file search. +fn plain_lines(lines: &[Line<'_>]) -> Vec { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + }) + .collect() +} + +/// Replace one interaction's search with matches over its displayed lines, returning the first line. +fn refresh_search( + interaction: &mut PreviewInteractionState, + lines: &[Line<'_>], + query: String, +) -> Option { + let matches = crate::search::find_matches(&query, &plain_lines(lines)); + let first_line = matches.first().map(|matched| matched.line); + interaction.search = Some(SearchState { + query, + matches, + current: 0, + }); + first_line +} + +/// Recompute one interaction's committed search against replacement displayed lines. +fn recompute_search(interaction: &mut PreviewInteractionState, lines: &[Line<'_>]) { + let Some(previous) = interaction.search.take() else { + return; + }; + if previous.query.is_empty() { + return; + } + let matches = crate::search::find_matches(&previous.query, &plain_lines(lines)); + interaction.search = Some(SearchState { + query: previous.query, + current: previous.current.min(matches.len().saturating_sub(1)), + matches, + }); +} + +/// Advance or retreat one interaction's committed search, returning its new line and wrap flag. +fn navigate_search( + interaction: &mut PreviewInteractionState, + forward: bool, +) -> Option<(usize, bool)> { + let search = interaction.search.as_mut()?; + if search.matches.is_empty() { + return None; + } + let wrapped = if forward { + search.current + 1 >= search.matches.len() + } else { + search.current == 0 + }; + search.current = if forward { + (search.current + 1) % search.matches.len() + } else { + (search.current + search.matches.len() - 1) % search.matches.len() + }; + Some((search.matches[search.current].line, wrapped)) +} + impl Controller { /// Scroll the content pane so 1-based source line `line_1based` is visible, landing the line near /// the top of the viewport. The source line is clamped to `[1, source_line_count]` (below 1 → @@ -14,14 +81,15 @@ impl Controller { /// rows, so the offset is the cumulative wrapped-row count of the lines BEFORE the target — the /// same mapping the wrapped-row total uses, so `:N` lands on source line N either way. (AC-3, AC-4) pub fn scroll_to_line(&mut self, line_1based: usize) { - let source_lines = self.content.lines.len(); + let source_lines = self.active_lines().len(); let line = line_1based.max(1).min(source_lines.max(1)); let offset = if self.effective_wrap() { self.wrapped_rows_before(line - 1) } else { line - 1 }; - self.content_scroll = (offset.min(u16::MAX as usize) as u16).min(self.max_content_scroll()); + self.active_interaction.vertical_scroll = + (offset.min(u16::MAX as usize) as u16).min(self.max_content_scroll()); } /// Open the go-to-line prompt (AC-1). Opens whenever a **file** is selected, in any view: in a @@ -31,11 +99,12 @@ impl Controller { /// nothing / a directory selected there is no file to address, so emit a one-line notice and open /// nothing. Snapshots the current content scroll into the prompt state. pub(super) fn open_go_to_line(&mut self) -> Effects { + self.prompt_target = PreviewTarget::Active; if self.selected_view_mode().is_some() { self.modal = Modal::Prompt(PromptState { mode: PromptMode::GoToLine, input: crate::prompt::PromptInput::new(), - saved_scroll: self.content_scroll, + saved_scroll: self.active_interaction.vertical_scroll, }); } else { self.action_notice = Some("Go to line: select a file first".into()); @@ -55,25 +124,48 @@ impl Controller { if self.modal.picker().is_some() || self.modal.finder().is_some() { return Effects::noop(); } - // File-gate: search requires a file to be selected (not a directory / nothing). + let target = if self.focus == Focus::Pinned { + if self.pinned_snapshot.is_none() { + self.action_notice = Some("Search: no pinned preview".into()); + return Effects::redraw(); + } + PreviewTarget::Pinned + } else { + PreviewTarget::Active + }; + self.prompt_target = target; + // File-gate applies only to the active tree-selected preview. A pin retains its applied + // document independently of subsequent tree selection or re-rooting. // selected_view_mode() returns Some(mode) iff a file node is currently selected. - if self.selected_view_mode().is_none() { + if target == PreviewTarget::Active && self.selected_view_mode().is_none() { self.action_notice = Some("Search: select a file first".into()); return Effects::redraw(); } // Zoom-on-open (7b): if the content pane isn't visible (narrow tree-only layout), zoom the // file so the user sees the content they're about to search. Mirrors the go-to-file finder. - if self.content_width == 0 { + if target == PreviewTarget::Active && self.active_interaction.viewport_width == 0 { self.zoomed = true; self.focus = Focus::Content; } // AC-20: opening a new search clears any prior committed SearchState so highlights from // the old query are gone before the new prompt opens. Clear first, then snapshot scroll. - self.search = None; + match target { + PreviewTarget::Active => self.active_interaction.search = None, + PreviewTarget::Pinned => { + if let Some(interaction) = self.pinned_interaction_mut() { + interaction.search = None; + } + } + } self.modal = Modal::Prompt(PromptState { mode: PromptMode::Search, input: crate::prompt::PromptInput::new(), - saved_scroll: self.content_scroll, + saved_scroll: match target { + PreviewTarget::Active => self.active_interaction.vertical_scroll, + PreviewTarget::Pinned => self + .pinned_interaction() + .map_or(0, |interaction| interaction.vertical_scroll), + }, }); Effects::redraw() } @@ -187,13 +279,21 @@ impl Controller { // Exception: an empty query commits nothing — clear search so no phantom // "Search: (no matches)" state persists after the prompt closes. KeyCode::Enter => { + let target = self.prompt_target(); let empty = self .modal .prompt() .map(|p| p.input.query().is_empty()) .unwrap_or(true); if empty { - self.search = None; + match target { + PreviewTarget::Active => self.active_interaction.search = None, + PreviewTarget::Pinned => { + if let Some(interaction) = self.pinned_interaction_mut() { + interaction.search = None; + } + } + } } self.modal = Modal::None; Effects::redraw() @@ -201,9 +301,20 @@ impl Controller { // AC-17: Esc cancels the search — restore the pre-open scroll snapshot and clear // the in-progress SearchState (no highlights remain after cancel). KeyCode::Esc => { + let target = self.prompt_target(); let saved_scroll = self.modal.prompt().map(|p| p.saved_scroll).unwrap_or(0); - self.content_scroll = saved_scroll; - self.search = None; + match target { + PreviewTarget::Active => { + self.active_interaction.vertical_scroll = saved_scroll; + self.active_interaction.search = None; + } + PreviewTarget::Pinned => { + if let Some(interaction) = self.pinned_interaction_mut() { + interaction.vertical_scroll = saved_scroll; + interaction.search = None; + } + } + } self.modal = Modal::None; Effects::redraw() } @@ -216,23 +327,17 @@ impl Controller { /// with ≥1 match: no search, a committed search with zero matches, or the prompt still open /// (AC-19). Scrolls the new current match into view. pub(super) fn next_match(&mut self) -> Effects { - // A committed search exists iff self.search is Some, non-empty, AND the prompt is closed. - let (len, current) = match self.search.as_ref() { - Some(s) if !s.matches.is_empty() && !self.prompt_open() => (s.matches.len(), s.current), - _ => return Effects::noop(), - }; - // Copy the fields we need before taking &mut self — borrow checker. - let wrapped = current + 1 >= len; - let next_current = (current + 1) % len; - // Compute the next match's line before mutating self.search. - let next_line = self.search.as_ref().unwrap().matches[next_current].line; - if let Some(s) = self.search.as_mut() { - s.current = next_current; + if self.prompt_open() { + return Effects::noop(); } + let target = self.focus_preview_target(); + let Some((next_line, wrapped)) = self.navigate_target_search(target, true) else { + return Effects::noop(); + }; if wrapped { self.action_notice = Some("Search: wrapped to first match".into()); } - self.scroll_to_line(next_line + 1); + self.scroll_target_to_line(target, next_line + 1); Effects::redraw() } @@ -240,23 +345,17 @@ impl Controller { /// first match back to the last with a notice (AC-16). Inert when there is no committed /// search with ≥1 match (AC-19). Scrolls the new current match into view. pub(super) fn prev_match(&mut self) -> Effects { - // A committed search exists iff self.search is Some, non-empty, AND the prompt is closed. - let (len, current) = match self.search.as_ref() { - Some(s) if !s.matches.is_empty() && !self.prompt_open() => (s.matches.len(), s.current), - _ => return Effects::noop(), - }; - // Copy the fields we need before taking &mut self — borrow checker. - let wrapped = current == 0; - let prev_current = (current + len - 1) % len; - // Compute the previous match's line before mutating self.search. - let prev_line = self.search.as_ref().unwrap().matches[prev_current].line; - if let Some(s) = self.search.as_mut() { - s.current = prev_current; + if self.prompt_open() { + return Effects::noop(); } + let target = self.focus_preview_target(); + let Some((prev_line, wrapped)) = self.navigate_target_search(target, false) else { + return Effects::noop(); + }; if wrapped { self.action_notice = Some("Search: wrapped to last match".into()); } - self.scroll_to_line(prev_line + 1); + self.scroll_target_to_line(target, prev_line + 1); Effects::redraw() } @@ -269,25 +368,22 @@ impl Controller { .prompt() .map(|p| p.input.query().to_string()) .unwrap_or_default(); - let plain = self.content_plain_lines(); - let matches = crate::search::find_matches(&q, &plain); - - // Selection policy: always choose match index 0 (first in document order). - // Incremental "stay near cursor" policies are deferred to a later task. - let current = 0; - - // AC-10: scroll so the current match is within the viewport. - // AC-18: do NOT touch content_scroll when there are no matches. - if !matches.is_empty() { - // matches[0].line is 0-based; scroll_to_line takes 1-based. - self.scroll_to_line(matches[0].line + 1); + let target = self.prompt_target(); + let first_line = match target { + PreviewTarget::Active => refresh_search( + &mut self.active_interaction, + &self.active_display.content().lines, + q, + ), + PreviewTarget::Pinned => self.pinned_snapshot.as_mut().and_then(|pin| { + let (document, interaction) = (&pin.document, &mut pin.interaction); + refresh_search(interaction, &document.content().lines, q) + }), + }; + if let Some(first_line) = first_line { + // `first_line` is 0-based; scroll_to_line takes 1-based. + self.scroll_target_to_line(target, first_line + 1); } - - self.search = Some(SearchState { - query: q, - matches, - current, - }); } /// Re-run a COMMITTED search (the prompt is closed, so [`refresh_search`] — which reads the live @@ -301,21 +397,46 @@ impl Controller { /// /// [`refresh_search`]: Self::refresh_search pub(super) fn recompute_committed_search(&mut self) { - let Some(prev) = self.search.take() else { - return; - }; - if prev.query.is_empty() { - return; + let lines = &self.active_display.content().lines; + recompute_search(&mut self.active_interaction, lines); + } + + pub(super) fn focus_preview_target(&self) -> PreviewTarget { + if self.focus == Focus::Pinned { + PreviewTarget::Pinned + } else { + PreviewTarget::Active + } + } + + pub(super) fn prompt_target(&self) -> PreviewTarget { + self.prompt_target + } + + fn navigate_target_search( + &mut self, + target: PreviewTarget, + forward: bool, + ) -> Option<(usize, bool)> { + match target { + PreviewTarget::Active => navigate_search(&mut self.active_interaction, forward), + PreviewTarget::Pinned => self + .pinned_interaction_mut() + .and_then(|interaction| navigate_search(interaction, forward)), + } + } + + fn scroll_target_to_line(&mut self, target: PreviewTarget, line: usize) { + match target { + PreviewTarget::Active => self.scroll_to_line(line), + PreviewTarget::Pinned => self.scroll_pinned_to_line(line), + } + } + + pub(super) fn target_search(&self, target: PreviewTarget) -> Option<&SearchState> { + match target { + PreviewTarget::Active => self.active_interaction.search.as_ref(), + PreviewTarget::Pinned => self.pinned_interaction().and_then(|p| p.search.as_ref()), } - let plain = self.content_plain_lines(); - let matches = crate::search::find_matches(&prev.query, &plain); - // Keep the same ordinal where possible; clamp if the reflow reduced the match count (an - // empty result leaves `current` at 0 with no matches — the status line reads "no matches"). - let current = prev.current.min(matches.len().saturating_sub(1)); - self.search = Some(SearchState { - query: prev.query, - matches, - current, - }); } } diff --git a/src/controller/lineselect.rs b/src/controller/lineselect.rs index 293e458e..4728e2f3 100644 --- a/src/controller/lineselect.rs +++ b/src/controller/lineselect.rs @@ -5,6 +5,7 @@ //! products, one keystroke apart. use super::*; +use crate::preview::PreviewSelection as LineSelectState; /// Format `rel_path` plus a 1-based line selection as `":"` for a single line /// (`start == end`) or `":-"` for a range, normalizing `start`/`end` to ascending @@ -74,104 +75,6 @@ fn char_index_at_col(s: &str, col: usize) -> usize { col.min(s.chars().count()) } -/// In-progress selection on the content pane. `anchor` is where the selection started, `marker` -/// the current cursor; both are 1-based source-line indices. Two granularities share this state: -/// -/// - **Line** (keyboard `j`/`k`, Shift-extend): whole source lines. `char_mode` is `false` and the -/// `*_col` carets are ignored — copy takes full lines. -/// - **Character** (mouse click-drag): `anchor_col`/`marker_col` are char carets (0-based char -/// indices into the *displayed* line, gutter included) pairing with `anchor`/`marker`. Set while -/// `char_mode` is `true` — copy takes the exact `anchor..marker` character span. -/// -/// A keyboard move reverts to line granularity (`char_mode = false`), so the two never tangle. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct LineSelectState { - anchor: usize, - marker: usize, - anchor_col: usize, - marker_col: usize, - char_mode: bool, -} - -impl LineSelectState { - /// Start a new selection collapsed onto a single `line` (line granularity). - pub(crate) fn new(line: usize) -> Self { - Self { - anchor: line, - marker: line, - anchor_col: 0, - marker_col: 0, - char_mode: false, - } - } - - /// Begin a character-granular selection collapsed at `(line, col)` — a mouse press. `col` is a - /// char caret into the displayed line; `line` is clamped to `[1, last]`. - pub(crate) fn begin_char(&mut self, line: usize, col: usize, last: usize) { - let l = Self::clamp(line, last); - self.anchor = l; - self.marker = l; - self.anchor_col = col; - self.marker_col = col; - self.char_mode = true; - } - - /// Extend a character-granular selection: move the marker to `(line, col)` while holding the - /// anchor — a mouse drag. `line` is clamped to `[1, last]`. - pub(crate) fn drag_char(&mut self, line: usize, col: usize, last: usize) { - self.marker = Self::clamp(line, last); - self.marker_col = col; - self.char_mode = true; - } - - /// Whether the selection is character-granular (a mouse drag), vs. whole-line (keyboard). - pub(crate) fn is_char_mode(&self) -> bool { - self.char_mode - } - - /// The character selection as an ordered `((lo_line, lo_col), (hi_line, hi_col))` pair — - /// ascending by line then column, so a drag in either direction reads the same. - pub(crate) fn char_span(&self) -> ((usize, usize), (usize, usize)) { - let a = (self.anchor, self.anchor_col); - let m = (self.marker, self.marker_col); - if a <= m { (a, m) } else { (m, a) } - } - - /// Move the marker to `line` (clamped to `[1, last]`) and collapse the selection onto it — - /// the anchor follows the marker. A keyboard move, so it reverts to line granularity. - pub(crate) fn move_to(&mut self, line: usize, last: usize) { - self.marker = Self::clamp(line, last); - self.anchor = self.marker; - self.char_mode = false; - } - - /// Move the marker to `line` (clamped to `[1, last]`) while holding the anchor fixed, - /// extending (or shrinking) the selection. A keyboard extend, so it reverts to line granularity. - pub(crate) fn extend_to(&mut self, line: usize, last: usize) { - self.marker = Self::clamp(line, last); - self.char_mode = false; - } - - /// The marker (cursor) line — 1-based. Exposed for the Presenter's line-select overlay (T-9), - /// which draws the marker emphasis distinct from the rest of the selection range. - pub(crate) fn marker(&self) -> usize { - self.marker - } - - /// The current selection as an ascending `(start, end)` pair. - pub(crate) fn selection(&self) -> (usize, usize) { - if self.anchor <= self.marker { - (self.anchor, self.marker) - } else { - (self.marker, self.anchor) - } - } - - fn clamp(line: usize, last: usize) -> usize { - line.max(1).min(last.max(1)) - } -} - impl Controller { /// Enter line-select mode with the marker on the top *visible* source line (AC-1, AC-15). /// @@ -200,7 +103,7 @@ impl Controller { // Drop any ambient selection AND in-flight drag so L mode starts clean: a still-held ambient // press left `drag = Some(ContentSelect)`, which the mouse drag arm would otherwise honor and // use to extend this freshly-opened L-mode selection from a press never made in L mode. - self.content_selection = None; + self.active_interaction.selection = None; self.drag = None; let source_mapped = self.selected_view_mode() == Some(ViewMode::SyntaxContent); if source_mapped && self.applied_seq == self.latest_seq { @@ -208,9 +111,9 @@ impl Controller { // valid now, so open synchronously. The top visible source line is mapped from the scroll // offset through `line_at_content_row`, so it is correct even when the `w` wrap override is // on (under wrap `content_scroll` is a wrapped display-row offset, NOT a source-line index). - let last = self.content.lines.len().max(1); + let last = self.active_lines().len().max(1); let top = self - .line_at_content_row(self.content_scroll as usize) + .line_at_content_row(self.active_interaction.vertical_scroll as usize) .clamp(1, last); self.modal = Modal::LineSelect(LineSelectState::new(top)); } else if let Some(path) = self @@ -244,7 +147,7 @@ impl Controller { /// newlines/tabs — hence the per-line approach). Bounds are clamped into `[1, line_count]`; an /// empty body yields an empty string. fn selected_lines_text(&self, start: usize, end: usize) -> String { - let total = self.content.lines.len(); + let total = self.active_lines().len(); if total == 0 { return String::new(); } @@ -271,8 +174,7 @@ impl Controller { /// source-mapped (see [`RenderResult::source`]); `None` otherwise. `content_source` is applied /// in lockstep with `content`, so `Some` guarantees index `n-1` IS displayed line `n`'s source. fn source_line(&self, n: usize) -> Option<&str> { - self.content_source - .as_ref() + self.active_source() .and_then(|src| src.get(n - 1)) .map(String::as_str) } @@ -311,7 +213,7 @@ impl Controller { /// [`gutter_len_of`](Self::gutter_len_of). Caller guarantees `n` is in /// `[1, content.lines.len()]`. fn filtered_display_line(&self, n: usize) -> String { - let joined: String = self.content.lines[n - 1] + let joined: String = self.active_lines()[n - 1] .spans .iter() .map(|s| s.content.as_ref()) @@ -333,18 +235,20 @@ impl Controller { /// [`crate::text_layout::wrap_row_starts`] — the same break-position port the wrapped scroll /// math counts rows with — so the caret lands on the character actually under the cursor. pub(crate) fn char_at_content_col(&self, col: u16, row: u16) -> (usize, usize) { - if self.content.lines.is_empty() { + if self.active_lines().is_empty() { return (1, 0); // no content to index (line-select can't open on an empty pane anyway) } - let last = self.content.lines.len(); + let last = self.active_lines().len(); let top = self.geom.content_inner.map_or(row, |c| c.y); let x = self.geom.content_inner.map_or(0, |c| c.x); - let display_row = self.content_scroll as usize + row.saturating_sub(top) as usize; + let display_row = + self.active_interaction.vertical_scroll as usize + row.saturating_sub(top) as usize; let line = self.line_at_content_row(display_row).clamp(1, last); // The pane-relative column, plus any horizontal scroll. The active overlay's leading glyph // column(s) (see `content_overlay_glyph_cols`) are subtracted per display row below — they // sit on the FIRST row only. - let within = (col.saturating_sub(x)) as usize + self.content_hscroll as usize; + let within = + (col.saturating_sub(x)) as usize + self.active_interaction.horizontal_scroll as usize; let overlay = self.content_overlay_glyph_cols(); let text = self.filtered_display_line(line); if !self.effective_wrap() { @@ -364,7 +268,7 @@ impl Controller { // the prefixed break positions. let starts = crate::text_layout::wrap_row_starts_prefixed( &text, - self.content_width.max(1) as usize, + self.active_interaction.viewport_width.max(1) as usize, overlay, ); let row_within = display_row @@ -399,7 +303,12 @@ impl Controller { /// cover the "Rendering…" placeholder (a file *is* selected mid-render) — /// `handle_column_mouse` blocks seeding a selection while a render is in flight instead. pub(super) fn copy_content_selection(&mut self) -> Effects { - let Some((lo, hi)) = self.content_selection.as_ref().map(|s| s.char_span()) else { + let Some((lo, hi)) = self + .active_interaction + .selection + .as_ref() + .map(|s| s.char_span()) + else { return Effects::redraw(); }; let is_file = self @@ -424,7 +333,7 @@ impl Controller { /// plain-text fallback / test stubs — or empty content). Both char-selection overlays subtract it /// so a highlight never paints the gutter. `line` is clamped into `[1, line_count]`. pub(crate) fn content_gutter_len(&self, line: usize) -> usize { - let total = self.content.lines.len(); + let total = self.active_lines().len(); if total == 0 { return 0; } @@ -459,7 +368,7 @@ impl Controller { (lo_line, lo_col): (usize, usize), (hi_line, hi_col): (usize, usize), ) -> String { - let total = self.content.lines.len(); + let total = self.active_lines().len(); if total == 0 { return String::new(); } @@ -676,10 +585,10 @@ impl Controller { _ => {} } - let Some(marker) = self.modal.line_select().map(|s| s.marker) else { + let Some(marker) = self.modal.line_select().map(|s| s.marker()) else { return Effects::noop(); }; - let last = self.content.lines.len(); + let last = self.active_lines().len(); // Classify the key into a marker target + whether it extends the selection. Shift+letter // arrives as the uppercase char (`J`/`K`); Shift+arrow as the arrow + the SHIFT bit. @@ -707,10 +616,10 @@ impl Controller { // same mapping `scroll_to_line` uses, so the two agree). If it fell above the top, pin the // top to it; if it fell below the bottom, pin the bottom row to it; then clamp to the last // screenful. - if let Some(marker) = self.modal.line_select().map(|s| s.marker) { + if let Some(marker) = self.modal.line_select().map(|s| s.marker()) { let row = self.content_row_of_line(marker); - let scroll = self.content_scroll as usize; - let height = self.content_height as usize; + let scroll = self.active_interaction.vertical_scroll as usize; + let height = self.active_interaction.viewport_height as usize; let new_scroll = if row < scroll { row } else if height > 0 && row >= scroll + height { @@ -718,7 +627,7 @@ impl Controller { } else { scroll }; - self.content_scroll = + self.active_interaction.vertical_scroll = (new_scroll.min(u16::MAX as usize) as u16).min(self.max_content_scroll()); } diff --git a/src/controller/mod.rs b/src/controller/mod.rs index 6ed8c613..605f9baa 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -29,9 +29,11 @@ mod infile; mod lineselect; mod mouse; mod picker; +mod pinned; use crate::annotation::AnnotationStore; use crate::finder::FinderState; +use crate::focus_policy::{ActionTarget, PreviewTarget, next_focus, target_for}; use crate::git::{Baseline, Status}; use crate::help::{HelpSection, HelpSectionState, HelpState}; use crate::herdr::HerdrCli; @@ -42,7 +44,11 @@ use crate::presenter::{ AnnotationEditorKind, AnnotationEditorView, AnnotationIndicatorsView, AnnotationOverviewView, AnnotationRowView, AnnotationTargetView, CharSelView, ContentSearch, DiscardConfirmView, FinderView, Focus, HelpView, LineSelectView, PaneGeometry, PickerRowView, PickerView, - ViewState, + PreviewProjection, PreviewViewports, ViewState, +}; +use crate::preview::{ + BranchState, PreviewDocument, PreviewInteractionState, PreviewOrigin, PreviewPresentation, + PreviewSelection, }; use crate::render::Renderers; use crate::root::Resolved; @@ -51,9 +57,9 @@ use crate::update::{self, NoticeSnapshot, UpdateState}; use crate::view_policy::{FileDescriptor, ViewMode, applicable_modes, default_mode}; use annotation::{AnnotationEditorState, AnnotationListState}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; -use lineselect::LineSelectState; +use pinned::PinnedSnapshot; use ratatui::layout::Position; -use ratatui::text::Text; +use ratatui::text::{Line, Text}; use std::collections::{BTreeMap, HashMap}; use std::io; use std::panic::AssertUnwindSafe; @@ -61,6 +67,93 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, mpsc}; use std::time::{Duration, Instant}; +// The line-select modal and the ambient active-preview selection share one interaction value. +// Keep the established controller-local name while the modal is migrated in-place. +type LineSelectState = PreviewSelection; + +/// Count the rows a preview's displayed lines occupy at its measured viewport width. +/// +/// Layout remains controller behavior: a later pinned preview can pass its own lines and +/// interaction value without teaching [`PreviewInteractionState`] about ratatui text layout. +fn rendered_rows( + interaction: &PreviewInteractionState, + lines: &[Line<'_>], + wrap: bool, + overlay_columns: usize, +) -> u16 { + let count = if wrap { + wrapped_rows_before(interaction, lines, lines.len(), overlay_columns) + } else { + lines.len() + }; + count.min(u16::MAX as usize) as u16 +} + +/// Count wrapped display rows before source line `n` at an interaction's measured width. +fn wrapped_rows_before( + interaction: &PreviewInteractionState, + lines: &[Line<'_>], + n: usize, + overlay_columns: usize, +) -> usize { + let width = interaction.viewport_width as usize; + lines + .iter() + .take(n) + .map(|line| crate::text_layout::line_wrapped_rows_prefixed(line, width, overlay_columns)) + .sum() +} + +/// Find the one-based source line shown at a zero-based display row. +fn line_at_row( + interaction: &PreviewInteractionState, + lines: &[Line<'_>], + row: usize, + wrap: bool, + overlay_columns: usize, +) -> usize { + if !wrap { + return row + 1; + } + let mut rows = 0usize; + for (index, line) in lines.iter().enumerate() { + rows += crate::text_layout::line_wrapped_rows_prefixed( + line, + interaction.viewport_width as usize, + overlay_columns, + ); + if row < rows { + return index + 1; + } + } + lines.len().max(1) +} + +/// Clamp an interaction's viewport offsets to the supplied displayed lines. +fn clamp_offsets( + interaction: &mut PreviewInteractionState, + lines: &[Line<'_>], + wrap: bool, + overlay_columns: usize, +) { + interaction.vertical_scroll = interaction.vertical_scroll.min( + rendered_rows(interaction, lines, wrap, overlay_columns) + .saturating_sub(interaction.viewport_height), + ); + let horizontal_max = if wrap { + 0 + } else { + (lines + .iter() + .map(Line::width) + .max() + .unwrap_or(0) + .min(u16::MAX as usize) as u16) + .saturating_sub(interaction.viewport_width) + }; + interaction.horizontal_scroll = interaction.horizontal_scroll.min(horizontal_max); +} + /// Tree-column width as a percentage of the pane: its default and the bounds the resize keys /// clamp to, so neither column can be squeezed to nothing. // The split range is owned by `crate::config` (the single source of truth), so the config-value @@ -71,6 +164,11 @@ const SPLIT_MIN: u16 = crate::config::MIN_TREE_WIDTH; const SPLIT_MAX: u16 = crate::config::MAX_TREE_WIDTH; /// How many percentage points one resize keypress moves the divider. const SPLIT_STEP: u16 = 5; +/// Bounds and one-key increment for the pinned/active preview divider. These are independent of +/// the outer tree/content split: a pin is the only precondition for mutating this ratio. +const PREVIEW_SPLIT_MIN: u16 = 20; +const PREVIEW_SPLIT_MAX: u16 = 80; +const PREVIEW_SPLIT_STEP: u16 = 5; // The floor for an INTERACTIVE resize (grow/shrink keys, divider drag), below the config/startup // floor `SPLIT_MIN`. A hand resize may pull the tree narrower than the startup minimum — down to the // same 10% the Presenter's `columns()` renders at — so on a wide pane the tree can be shrunk below a @@ -264,6 +362,116 @@ enum EmptyReason { NoFiles, } +/// What the active content region currently displays. +/// +/// Only `Document` is a settled file preview. Keeping loading, directory (including a rendered +/// status-mode directory diff), and empty-tree output as distinct variants makes it impossible for +/// the later pin lifecycle to mistake guidance or an in-flight placeholder for a pinnable file. +enum ActiveDisplay { + Loading { + content: Arc>, + previous_title: Option, + previous_presentation: Option, + previous_origin: Option, + }, + Directory { + content: Arc>, + notices: Vec, + presentation: PreviewPresentation, + }, + EmptyTree(Arc>), + Document(PreviewDocument), +} + +impl ActiveDisplay { + fn content(&self) -> &Text<'static> { + match self { + Self::Loading { content, .. } | Self::EmptyTree(content) => content, + Self::Directory { content, .. } => content, + Self::Document(document) => document.content(), + } + } + + /// A shared handle to the displayed content — what every per-frame projection clones. + fn shared_content(&self) -> Arc> { + match self { + Self::Loading { content, .. } | Self::EmptyTree(content) => Arc::clone(content), + Self::Directory { content, .. } => Arc::clone(content), + Self::Document(document) => document.shared_content(), + } + } + + fn notices(&self) -> &[String] { + match self { + Self::Directory { notices, .. } => notices, + Self::Document(document) => document.notices(), + Self::Loading { .. } | Self::EmptyTree(_) => &[], + } + } + + fn source(&self) -> Option<&[String]> { + match self { + Self::Document(document) => document.source(), + _ => None, + } + } + + fn presentation(&self) -> Option<&PreviewPresentation> { + match self { + Self::Directory { presentation, .. } => Some(presentation), + Self::Document(document) => Some(document.presentation()), + Self::Loading { + previous_presentation, + .. + } => previous_presentation.as_ref(), + Self::EmptyTree(_) => None, + } + } + + fn document(&self) -> Option<&PreviewDocument> { + match self { + Self::Document(document) => Some(document), + _ => None, + } + } + + /// Replace the settled document as one complete value when a layout-only projection changes. + /// The public preview model remains immutable, while the controller keeps its captured + /// presentation in lockstep with what the active pane projects. + fn replace_document_presentation(&mut self, presentation: PreviewPresentation) { + let Self::Document(document) = self else { + return; + }; + *document = document.with_presentation(presentation); + } + + fn displayed_origin(&self) -> Option<&PreviewOrigin> { + match self { + Self::Loading { + previous_origin, .. + } => previous_origin.as_ref(), + Self::Document(document) => Some(document.origin()), + Self::Directory { .. } | Self::EmptyTree(_) => None, + } + } + + fn is_loading(&self) -> bool { + matches!(self, Self::Loading { .. }) + } + + fn title(&self) -> Option { + match self { + Self::Loading { previous_title, .. } => previous_title.clone(), + Self::Document(document) => document + .origin() + .absolute_path() + .file_name() + .map(|name| name.to_string_lossy().into_owned()), + Self::Directory { .. } | Self::EmptyTree(_) => None, + } + } +} + impl EmptyReason { /// The empty-state guidance copy for this case. fn label(self) -> &'static str { @@ -343,6 +551,10 @@ impl Effects { struct RenderJob { seq: u64, path: PathBuf, + /// Root/worktree identity captured when this render was dispatched. + root: PathBuf, + /// Branch identity captured with `root`; never inferred from the cursor when the result lands. + branch: BranchState, /// Repo-root-relative path for the git diff query (`None` if outside the root). rel: Option, mode: ViewMode, @@ -367,6 +579,13 @@ struct RenderJob { pane_width: Option, /// Which command a Diff/FullDiff render delegates to (`D`). Ignored by other modes. diff_render_mode: DiffRenderMode, + /// Presentation facts captured for the exact render request. + presentation: PreviewPresentation, +} + +struct RenderCompletion { + job: RenderJob, + result: RenderResult, } /// A re-root's off-thread git result: the working-tree status (tree markers, AC-7) and the @@ -396,7 +615,7 @@ enum Modal { Finder(FinderState), Prompt(PromptState), Help(HelpState), - LineSelect(LineSelectState), + LineSelect(PreviewSelection), Annotations(AnnotationListState), AnnotationEditor(AnnotationEditorState), /// The confirm raised when an action would discard unexported annotations. Carries what to do @@ -485,7 +704,7 @@ impl Modal { _ => None, } } - fn line_select(&self) -> Option<&LineSelectState> { + fn line_select(&self) -> Option<&PreviewSelection> { match self { Modal::LineSelect(s) => Some(s), _ => None, @@ -493,7 +712,7 @@ impl Modal { } // Used by the line-select key handler (`handle_line_select_key`); the state exists here from // T-3 so the accessor pair mirrors picker/finder/prompt/help. - fn line_select_mut(&mut self) -> Option<&mut LineSelectState> { + fn line_select_mut(&mut self) -> Option<&mut PreviewSelection> { match self { Modal::LineSelect(s) => Some(s), _ => None, @@ -558,19 +777,19 @@ pub struct Controller { /// the cursor it is navigation state: reset on a re-root (AC-13), not carried. tree_hscroll: u16, focus: Focus, + /// The interaction state an open in-file prompt was created for. It is deliberately not + /// inferred from later focus changes, so incremental search cannot leak across previews. + prompt_target: PreviewTarget, /// The pane width the run loop last observed (session state for the narrow-split flag, /// AC-21); the Presenter still lays out from the live frame, never this. width: u16, - /// Vertical scroll offset of the content pane, in lines. Reset to the top whenever a new - /// render is dispatched (a new file / mode / baseline). - content_scroll: u16, - /// Horizontal scroll offset of the content pane, in columns (only used when not wrapping). - /// Reset to the left edge on a new render. - content_hscroll: u16, - /// The content viewport `(width, height)` the Presenter last drew into. Used to clamp - /// `content_scroll` so the user cannot scroll past the last screenful. - content_width: u16, - content_height: u16, + /// Mutable viewport, search, and displayed-text selection state for the active preview. + /// A later pinned snapshot owns a distinct clone of this value. + active_interaction: PreviewInteractionState, + /// The one optional frozen document and independently evolving interaction state. + pinned_snapshot: Option, + /// The pinned preview's share of the preview area, preserved across removal/recreation. + preview_split_pct: u16, /// How many lines (or finder list items, or help-overlay lines) one mouse-wheel event advances /// — the effective **scroll step** (config `scroll_lines`, else [`crate::config::DEFAULT_SCROLL_LINES`]). /// Set once at startup via [`apply_scroll_lines`](Self::apply_scroll_lines). Held as `isize` @@ -626,28 +845,9 @@ pub struct Controller { changed: BTreeMap, /// Per-file view-mode override set by cycling (AC-11); absent ⇒ the policy default. overrides: HashMap, - /// The content pane's current text and its notices (truncation/fallback). - content: Text<'static>, - content_notices: Vec, - /// The raw source lines behind the displayed content when it is source-mapped - /// (`SyntaxContent`), 1:1 with `content.lines` — see [`RenderResult::source`]. Applied and - /// cleared in lockstep with `content` (`poll` / `clear_content`), so the copy paths can trust - /// that when it is `Some`, index `n-1` IS displayed line `n`'s source. - content_source: Option>, - /// The path of the file whose content is currently displayed in the pane — the title's - /// source of truth, so the border label switches in lockstep with the body. `None` - /// while no file's content has landed yet (launch, a re-root, or a directory/empty tree - /// selection — the title then falls back to the selected node's name or "Content"). Updated - /// only by [`poll`](Self::poll) when a render result is applied, and cleared by - /// [`clear_content`](Self::clear_content); a render in flight does NOT update it ahead of the - /// body, so the pane never shows a new file's title over the old file's body. - content_path: Option, - /// True iff an off-thread render for a file is in flight — set when [`dispatch_render`] - /// sends a `RenderJob`, cleared when [`poll`](Self::poll) applies the matching result (and - /// by [`clear_content`](Self::clear_content), which sends no job). The Presenter uses this - /// to pick a neutral title while the body shows the loading placeholder, so the title never - /// jumps to a freshly-selected file before its content arrives. - content_rendering: bool, + /// The complete applied active render, or a typed non-file display state. This is the sole + /// source of truth for active content, content notices/source, presentation, and origin path. + active_display: ActiveDisplay, /// A transient notice from the last action (e.g. an editor-launch failure); shown until /// the next intent is handled. action_notice: Option, @@ -668,7 +868,7 @@ pub struct Controller { /// Render dispatch to the worker thread (AC-23). `latest_seq` is the most recently /// dispatched job; a `poll`ed result with a smaller seq is stale and dropped. job_tx: mpsc::Sender, - result_rx: mpsc::Receiver<(u64, RenderResult)>, + result_rx: mpsc::Receiver, latest_seq: u64, /// The `seq` of an in-flight markdown re-render triggered by a content-pane *resize* /// ([`rerender_markdown_for_width`]), as opposed to a selection change. When [`poll`] applies a @@ -688,13 +888,6 @@ pub struct Controller { /// What the held left button is dragging (divider resize or a scrollbar), so the release is /// treated as the end of the drag, not a click. `None` ⇒ no drag in progress. drag: Option, - /// An ambient character selection dragged out in the content pane during normal navigation, held - /// OUTSIDE [`Modal`] so `Modal::None` stays in force and every keyboard binding keeps its normal - /// meaning — that is what makes it ambient, not a mode. Reuses the [`LineSelectState`] char - /// primitives (always char-mode; `char_at_content_col` maps wrapped views too). Mutually - /// exclusive with L line-select mode by construction: created only in `handle_column_mouse`, - /// which runs only for `Modal::None`. Auto-copied on release. - content_selection: Option, /// The last complete remote-notice state. Background results replace this value atomically, /// so release and spotlight state never land on different redraws. notice_snapshot: NoticeSnapshot, @@ -770,15 +963,6 @@ pub struct Controller { /// current" from "a render is still coming" — so `:N` only jumps in-place when the source-mapped /// content is actually applied, and otherwise queues against the in-flight render (AC-3/AC-7). applied_seq: u64, - /// Live incremental-search state: the most-recently-typed query, the matches it produced, - /// and which match is current. `None` until the first keystroke in a Search prompt; `Some` - /// (even with empty matches) once typing begins. Enter-commit retains it so `n`/`N` can - /// navigate the committed matches (AC-14). Cleared to `None` on Esc-cancel (AC-17), on - /// opening a new search (AC-20), and at the top of `dispatch_render` so any displayed-content - /// change (file-select, view-cycle, baseline-toggle, refresh, re-root, etc.) wipes a committed - /// search + its highlighting (AC-20). The incremental-typing path (`refresh_search`) does NOT - /// call `dispatch_render`, so live typing is never wiped by that clear. - search: Option, /// The OS opener seam used by the `O` / `R` hand-offs (open-with-default-app / reveal-in-file- /// manager). Injected post-construction via [`set_opener`](Self::set_opener) (like /// [`herdr`](Self::herdr)) so the controller stays hermetic in tests. `None` until then. @@ -854,11 +1038,11 @@ impl Controller { status_mode: false, git_status: BTreeMap::new(), focus: Focus::Tree, + prompt_target: PreviewTarget::Active, width: 0, - content_scroll: 0, - content_hscroll: 0, - content_width: 0, - content_height: 0, + active_interaction: PreviewInteractionState::default(), + pinned_snapshot: None, + preview_split_pct: 50, wheel_step: crate::config::DEFAULT_SCROLL_LINES as isize, split_pct: SPLIT_DEFAULT, tree_position: crate::config::TreePosition::Left, @@ -869,11 +1053,7 @@ impl Controller { host_zoomed: false, changed: BTreeMap::new(), overrides: HashMap::new(), - content: Text::raw(""), - content_notices: Vec::new(), - content_source: None, - content_path: None, - content_rendering: false, + active_display: ActiveDisplay::EmptyTree(Arc::new(Text::raw(""))), action_notice: None, flash: None, annotations: AnnotationStore::new(), @@ -889,7 +1069,6 @@ impl Controller { geom: PaneGeometry::default(), last_click: None, drag: None, - content_selection: None, notice_snapshot: NoticeSnapshot::default(), settings_display: None, keybindings_display: None, @@ -902,7 +1081,6 @@ impl Controller { open_range_flash: None, pending_line_select: None, applied_seq: 0, - search: None, herdr: None, our_workspace_id: None, base_branch, @@ -926,9 +1104,9 @@ impl Controller { fn spawn_worker( git: Arc, content: Box, - ) -> (mpsc::Sender, mpsc::Receiver<(u64, RenderResult)>) { + ) -> (mpsc::Sender, mpsc::Receiver) { let (job_tx, job_rx) = mpsc::channel::(); - let (result_tx, result_rx) = mpsc::channel::<(u64, RenderResult)>(); + let (result_tx, result_rx) = mpsc::channel::(); std::thread::spawn(move || { while let Ok(mut job) = job_rx.recv() { // Collapse any backlog: under rapid navigation only the most recent selection @@ -975,7 +1153,7 @@ impl Controller { notices: vec!["the renderer failed unexpectedly; showing a placeholder".into()], source: None, }); - if result_tx.send((job.seq, result)).is_err() { + if result_tx.send(RenderCompletion { job, result }).is_err() { break; // controller gone } } @@ -988,8 +1166,9 @@ impl Controller { /// factory (ADR-0004), and respawn the render worker — overwriting `job_tx`/`result_rx` drops /// the old sender, so the previous worker (which owns the old providers) exits. A fresh /// [`TreeModel`] and reset navigation/view state follow (AC-13), while the user's *preferences* - /// — `show_ignored`, `hide_hidden`, `changed_only`, `split_pct`, `wrap_override`, `baseline` — - /// are carried across unchanged (AC-12). The structural re-root (resolve + fresh tree + worker respawn + + /// — `show_ignored`, `hide_hidden`, `changed_only`, `split_pct`, `wrap_override`, `baseline`, + /// and the frozen pinned preview plus its ratio — are carried across unchanged (AC-12). The + /// structural re-root (resolve + fresh tree + worker respawn + /// carried prefs + nav reset) is **synchronous**, so the tree is immediately navigable; the /// heavier git status + changed-set fills in **asynchronously**, applied by [`poll`] (AC-17), /// so input is never blocked. Finally the first frame is rendered. A missing or @@ -1075,14 +1254,13 @@ impl Controller { // too (no-op if `Z` was never used); otherwise the pane would stay full-screen while the // plugin resets to the split (`herdr` is session-level and survives the re-root). self.leave_host_zoom(); - self.content_scroll = 0; - self.content_hscroll = 0; + self.active_interaction.vertical_scroll = 0; + self.active_interaction.horizontal_scroll = 0; self.tree_hscroll = 0; self.overrides.clear(); - // The old root's rendered content is invalid under the new root — drop the displayed-file - // path so the title falls back to a neutral label until the new selection's render lands - //. `dispatch_render` below sets `content_rendering` and the loading placeholder. - self.content_path = None; + // The old root's rendered content is invalid under the new root. The dispatch below + // installs a typed loading state until the new root's first document lands. + self.active_display = ActiveDisplay::EmptyTree(Arc::new(Text::raw(""))); let cleared_annotations = self.annotations.clear(); self.action_notice = (cleared_annotations > 0).then(|| { format!( @@ -1104,7 +1282,10 @@ impl Controller { self.last_click = None; // PREFERENCES ARE CARRIED (AC-12) — deliberately NOT reset: show_ignored, hide_hidden, - // changed_only, status_mode, split_pct, tree_position, tree_max_cols, split_manual, wrap_override, baseline keep their current values. The fresh + // changed_only, status_mode, split_pct, tree_position, tree_max_cols, split_manual, + // wrap_override, baseline, pinned_snapshot, and preview_split_pct keep their current + // values. The frozen pin is a root-independent reference; active/root-bound content above + // is reset. The fresh // TreeModel starts with default filter flags. `show_ignored` and `hide_hidden` are // git-independent, so apply them now. The changed-only *filter* is NOT applied here: it // must be applied against the REAL changed-set, which `dispatch_status_refresh` computes @@ -1199,7 +1380,21 @@ impl Controller { &self.root } pub fn content(&self) -> &Text<'static> { - &self.content + self.active_display.content() + } + + /// The complete settled active file preview, or `None` for loading, directory, and empty-tree + /// displays. This is the copy seam used by the pinned-snapshot lifecycle. + pub fn active_document(&self) -> Option<&PreviewDocument> { + self.active_display.document() + } + + fn active_lines(&self) -> &[Line<'static>] { + &self.active_display.content().lines + } + + fn active_source(&self) -> Option<&[String]> { + self.active_display.source() } /// The transient action notice from the last intent, if any. Exposed for tests that need @@ -1271,7 +1466,7 @@ impl Controller { if let Some(n) = &self.action_notice { out.push(n.clone()); } - out.extend(self.content_notices.iter().cloned()); + out.extend(self.active_display.notices().iter().cloned()); out } @@ -1539,7 +1734,7 @@ impl Controller { self.tree_max_cols = cols.max(crate::config::MIN_TREE_MAX_COLS); } - /// Record the content viewport `(width, height)` the Presenter last drew into, so content + /// Record the active content viewport `(width, height)` the Presenter last drew into, so content /// scrolling can be clamped to it. Called by the run loop after each draw. /// /// Returns `true` when the run loop should redraw immediately: a deferred launch-open zoom @@ -1559,16 +1754,17 @@ impl Controller { need_redraw = true; } } - if width == self.content_width && height == self.content_height { + if width == self.active_interaction.viewport_width + && height == self.active_interaction.viewport_height + { return need_redraw; // unchanged — avoid recomputing the clamp on every idle draw } - let width_changed = width != self.content_width; - self.content_width = width; - self.content_height = height; + let width_changed = width != self.active_interaction.viewport_width; + self.active_interaction.viewport_width = width; + self.active_interaction.viewport_height = height; // A smaller viewport shrinks the max offset, so an existing scroll could now point past // the end, leaving blank space; re-clamp both axes to the new geometry. - self.content_scroll = self.content_scroll.min(self.max_content_scroll()); - self.content_hscroll = self.content_hscroll.min(self.max_content_hscroll()); + self.clamp_active_interaction(); // A width-sensitive delegate (glow fit-to-pane markdown, or a non-`Raw` delta diff mode) // must reflow to the new width, preserving scroll and search (unlike a selection-change // render); `rerender_after_resize` gates per-mode whether that applies. A height-only @@ -1579,6 +1775,15 @@ impl Controller { need_redraw } + /// Receive all preview viewport feedback from the Presenter. The active-only compatibility + /// seam remains the owner of launch-open zoom and width-sensitive rerendering; a pinned + /// interaction state is added by the lifecycle task. + pub fn set_preview_viewports(&mut self, viewports: PreviewViewports) -> bool { + let redraw = self.set_content_viewport(viewports.active.0, viewports.active.1); + self.set_pinned_viewport(viewports.pinned); + redraw + } + /// Receive the hit-test geometry the Presenter drew this frame (fed back from the draw /// closure), so the next mouse event is mapped against the live layout. /// @@ -1615,7 +1820,20 @@ impl Controller { /// The content scroll offset (lines). Exposed for the Presenter wiring and tests. pub fn content_scroll(&self) -> u16 { - self.content_scroll + self.active_interaction.vertical_scroll + } + + /// The active preview's complete mutable interaction state. + pub fn active_interaction(&self) -> &PreviewInteractionState { + &self.active_interaction + } + + /// Mutably access the active preview's interaction state. + /// + /// The controller continues to clamp and project this state through its regular input and + /// presentation paths; this accessor is the copy seam used by the later pinned snapshot. + pub fn active_interaction_mut(&mut self) -> &mut PreviewInteractionState { + &mut self.active_interaction } /// The most recently dispatched render's sequence number. @@ -1640,6 +1858,8 @@ impl Controller { // for the wrap decision — `visible_nodes()` is the hot, per-frame path. let nodes = self.tree.visible_nodes(); let selected = self.tree.cursor(); + // Active wrapping responds immediately to the live `w` preference while a width-sensitive + // reflow is pending; the settled document captures the same value when that render lands. let wrap = self.wrap_for(nodes.get(selected)); // The wrapped-aware content row total, so the content vertical scrollbar sizes/positions // against the SAME extent the scroll clamp uses (raw `lines.len()` undercounts under wrap, @@ -1649,37 +1869,103 @@ impl Controller { // Inset the transformed views (rendered markdown / diff) one column from the left border so // their delegate output — which starts at column 0 — doesn't hug it; syntax/plain files // already get that gap from bat's line-number gutter, so they stay flush (no double gap). - // Keyed off the DISPLAYED content's file (`content_path`, the title's source of truth), so - // the gap switches in lockstep with the body — never off a still-loading selection. - let content_pad_left = self.content_path.as_ref().is_some_and(|p| { - matches!( - self.effective_mode(p), - ViewMode::RenderedMarkdown | ViewMode::Diff | ViewMode::FullDiff - ) - }); + // Captured with the applied display so the gap changes in lockstep with the body. + let content_pad_left = self + .active_display + .presentation() + .is_some_and(PreviewPresentation::pad_left); // Gutter width for a character selection's highlight (0 when not applicable); computed once // here so the line-select snapshot below stays a pure read. let sel_gutter = self.selection_gutter_len(); + let line_select = self + .modal + .line_select() + .map(|s| { + let (start, end) = s.selection(); + let char_sel = if s.is_char_mode() { + let ((sl, sc), (el, ec)) = s.char_span(); + Some(CharSelView { + start_line: sl, + start_col: sc, + end_line: el, + end_col: ec, + gutter: sel_gutter, + }) + } else { + None + }; + LineSelectView { + marker: s.marker(), + start, + end, + char_sel, + passive: false, + } + }) + .or_else(|| { + self.open_range_flash.as_ref().map(|f| LineSelectView { + marker: f.start, + start: f.start, + end: f.end, + char_sel: None, + passive: true, + }) + }); + let selection = self + .active_interaction + .selection + .as_ref() + .filter(|s| { + let (a, b) = s.char_span(); + a != b + }) + .map(|s| { + let ((sl, sc), (el, ec)) = s.char_span(); + CharSelView { + start_line: sl, + start_col: sc, + end_line: el, + end_col: ec, + gutter: self.content_gutter_len(sl), + } + }); ViewState { nodes, selected, - content: self.content.clone(), - notices: self.notices(), - flash: self.flash.as_ref().map(|f| crate::presenter::FlashLine { - text: f.text.clone(), - dim: f.dim, - }), + active: PreviewProjection { + content: self.active_display.shared_content(), + notices: self.notices(), + flash: self.flash.as_ref().map(|f| crate::presenter::FlashLine { + text: f.text.clone(), + dim: f.dim, + }), + title: self.active_display.title(), + rendering: self.active_display.is_loading(), + scroll: self.active_interaction.vertical_scroll, + hscroll: self.active_interaction.horizontal_scroll, + rows: content_rows, + wrap, + pad_left: content_pad_left, + search: self + .active_interaction + .search + .as_ref() + .map(|s| ContentSearch { + matches: s.matches.clone(), + current: s.current, + }), + line_select, + selection, + origin: None, + }, + pinned: self.pinned_projection(), focus: self.focus, width: self.width, - content_scroll: self.content_scroll, - content_hscroll: self.content_hscroll, // Last frame's tree offset, so the Presenter scrolls minimally from it (#45): selecting // a row already in view — e.g. a mouse click — never jumps the viewport. tree_scroll: self.geom.tree_scroll, tree_hscroll: self.tree_hscroll, - content_rows, - wrap, - content_pad_left, + preview_split_pct: self.preview_split_pct, split_pct: self.split_pct, tree_position: self.tree_position, tree_max_cols: self.tree_max_cols, @@ -1702,86 +1988,20 @@ impl Controller { .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(), branch: self.current_branch.clone(), - prompt: self.bottom_line(), - // the content pane's border title. `content_path` is the displayed content's - // file (set by `poll` when a render lands, cleared by `clear_content`/re-root), so the - // title switches in lockstep with the body — it never jumps to a freshly-selected file - // before that file's content arrives. `None` while no file's content has landed (launch, - // re-root, or a directory/empty selection); the Presenter then falls back to the selected - // node's name (a directory) or "Content". `content_rendering` tells the Presenter a - // render is in flight so the `None` fallback doesn't pick up the new (still-loading) - // selection's name and re-introduce the title-ahead-of-body bug. - content_title: self - .content_path - .as_ref() - .and_then(|p| p.file_name()) - .map(|s| s.to_string_lossy().into_owned()), - content_rendering: self.content_rendering, - // Populate the highlight overlay from the committed/live search state so the Presenter - // overlays matches via highlight::apply. `None` when no search is active → draw_content - // falls through to `state.content.clone()`, byte-identical to the prior path. - search: self.search.as_ref().map(|s| ContentSearch { - matches: s.matches.clone(), - current: s.current, - }), - // Populate the line-select overlay from the active modal so the Presenter draws the - // marker + selection highlight (AC-1, AC-7). When the modal is closed, a launch - // open-range flash (if any) reuses the same view slot as a *passive* highlight only. - line_select: self - .modal - .line_select() - .map(|s| { - let (start, end) = s.selection(); - // A mouse drag carries character carets → the overlay highlights just those chars; - // a keyboard selection has none → the whole-line highlight. - let char_sel = if s.is_char_mode() { - let ((sl, sc), (el, ec)) = s.char_span(); - Some(CharSelView { - start_line: sl, - start_col: sc, - end_line: el, - end_col: ec, - gutter: sel_gutter, - }) - } else { - None - }; - LineSelectView { - marker: s.marker(), - start, - end, - char_sel, - passive: false, - } + // AC-12: name the pin's worktree only when it is not the one being viewed. Compare the + // full root paths, never the basenames — two checkouts of the same repo commonly share + // a basename, and a basename comparison would hide exactly the cross-worktree pin the + // label exists to mark. + pinned_foreign_root: self.pinned_snapshot.as_ref().and_then(|pin| { + let origin_root = pin.document.origin().root(); + (origin_root != self.root).then(|| { + origin_root + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| origin_root.to_string_lossy().into_owned()) }) - .or_else(|| { - self.open_range_flash.as_ref().map(|f| LineSelectView { - marker: f.start, - start: f.start, - end: f.end, - char_sel: None, - passive: true, - }) - }), - // Snapshot the ambient selection only when non-collapsed, so a bare click never paints a - // zero-width highlight (`draw_content` gives `line_select` precedence if both were set). - content_selection: self - .content_selection - .as_ref() - .filter(|s| { - let (a, b) = s.char_span(); - a != b - }) - .map(|s| { - let ((sl, sc), (el, ec)) = s.char_span(); - CharSelView { - start_line: sl, - start_col: sc, - end_line: el, - end_col: ec, - gutter: self.content_gutter_len(sl), - } - }), + }), + prompt: self.bottom_line(), help: self.help_view(), } } @@ -1802,7 +2022,7 @@ impl Controller { } crate::infile::PromptMode::Search => { let q = p.input.query(); - let count = self.search_count_fragment(q); + let count = self.search_count_fragment(q, self.prompt_target); Some(format!("Search: {q}{count}")) } } @@ -1816,11 +2036,11 @@ impl Controller { /// - Empty query → empty string (nothing appended; label reads `Search: `). /// - Non-empty, 0 matches → ` (no matches)`. /// - Non-empty, ≥1 match → ` ({current+1}/{total})`. - fn search_count_fragment(&self, query: &str) -> String { + fn search_count_fragment(&self, query: &str, target: PreviewTarget) -> String { if query.is_empty() { return String::new(); } - match &self.search { + match self.target_search(target) { None => String::new(), Some(s) if s.matches.is_empty() => " (no matches)".to_owned(), Some(s) => format!(" ({}/{})", s.current + 1, s.matches.len()), @@ -1834,7 +2054,7 @@ impl Controller { /// - ≥1 match: `Search: {query} ({current+1}/{total}) · n next · N prev · Esc clear` /// - 0 matches: `Search: {query} (no matches) · Esc clear` fn search_status_line(&self) -> Option { - let s = self.search.as_ref()?; + let s = self.target_search(self.focus_preview_target())?; let q = &s.query; let line = if s.matches.is_empty() { format!("Search: {q} (no matches) · Esc clear") @@ -1853,15 +2073,30 @@ impl Controller { /// plain text) wraps; diffs and code stay unwrapped so their columns align. Takes the node so /// the draw path needn't re-walk. fn wrap_for(&self, node: Option<&Node>) -> bool { - let default = match node { - Some(n) if n.kind == NodeKind::File => { - // Only prose wraps by default; diffs (compact and full-context) and code keep their - // lines so columns and the line-number gutter stay aligned. - matches!(self.effective_mode(&n.path), ViewMode::RenderedMarkdown) - } - _ => false, + let mode = match node { + Some(n) if n.kind == NodeKind::File => Some(self.effective_mode(&n.path)), + _ => None, }; - self.wrap_override.unwrap_or(default) + self.wrap_override + .unwrap_or(mode == Some(ViewMode::RenderedMarkdown)) + } + + fn preview_presentation(&self, mode: ViewMode) -> PreviewPresentation { + let wrap = self + .wrap_override + .unwrap_or(mode == ViewMode::RenderedMarkdown); + let pad_left = matches!( + mode, + ViewMode::RenderedMarkdown | ViewMode::Diff | ViewMode::FullDiff + ); + PreviewPresentation::new(mode, wrap, pad_left) + } + + fn captured_branch(&self) -> BranchState { + self.current_branch + .clone() + .map(BranchState::Named) + .unwrap_or(BranchState::Detached) } // ---- intent handling --------------------------------------------------------------- @@ -1901,6 +2136,10 @@ impl Controller { { return Effects::noop(); } + if target_for(self.focus, intent) == ActionTarget::Rejected { + self.action_notice = Some("Unavailable from pinned preview".into()); + return Effects::redraw(); + } match intent { Intent::NavUp => self.navigate(-1), Intent::NavDown => self.navigate(1), @@ -1929,8 +2168,11 @@ impl Controller { Intent::ToggleFocus => self.toggle_focus(), Intent::ShrinkTree => self.resize_split(-(SPLIT_STEP as i16)), Intent::GrowTree => self.resize_split(SPLIT_STEP as i16), + Intent::ShrinkPreview => self.resize_preview_split(-(PREVIEW_SPLIT_STEP as i16)), + Intent::GrowPreview => self.resize_preview_split(PREVIEW_SPLIT_STEP as i16), Intent::ToggleWrap => self.toggle_wrap(), Intent::ToggleZoom => self.toggle_zoom(), + Intent::PinPreview => self.pin_active_preview(), Intent::Refresh => self.refresh(), Intent::DismissUpdate => self.dismiss_update(), Intent::SwitchWorktree => self.open_worktree_picker(), @@ -1955,13 +2197,16 @@ impl Controller { Intent::TreeScrollRight => match self.focus { Focus::Tree => self.scroll_tree_h_focus(HSCROLL_STEP as i32), // AC-2: unchanged Focus::Content => { - if self.content.lines.is_empty() { + if self.active_lines().is_empty() { Effects::noop() // AC-3: no rendered content → inert } else { self.enter_line_select_at_top(); // AC-1 Effects::redraw() } } + // Keep this inert if the focus policy changes. The pin has neither a tree row + // nor a line-select action, so this key must never abort the viewer. + Focus::Pinned => Effects::noop(), }, Intent::ShowHelp => self.open_help(), Intent::Close => self.close_or_unzoom(), @@ -1976,7 +2221,8 @@ impl Controller { /// pane has no measured geometry (before the first layout pass), so a page key is never a no-op. fn page_step(&self) -> isize { let rows = match self.focus { - Focus::Content => self.content_height, + Focus::Content => self.active_interaction.viewport_height, + Focus::Pinned => self.pinned_viewport_height(), Focus::Tree => self.geom.tree_inner.map_or(0, |inner| inner.height), }; (rows as isize).max(1) @@ -1992,6 +2238,10 @@ impl Controller { self.scroll_content(delta); Effects::redraw() } + Focus::Pinned => { + self.scroll_pinned(delta); + Effects::redraw() + } Focus::Tree => { self.tree.move_cursor(delta); self.dispatch_render(); // new selection → re-render (and reset the scroll) @@ -2063,14 +2313,74 @@ impl Controller { /// above the first line or past the last screenful. fn scroll_content(&mut self, delta: isize) { let max = self.max_content_scroll() as isize; - let next = (self.content_scroll as isize + delta).clamp(0, max); - self.content_scroll = next as u16; + let next = (self.active_interaction.vertical_scroll as isize + delta).clamp(0, max); + self.active_interaction.vertical_scroll = next as u16; + } + + fn pinned_viewport_height(&self) -> u16 { + self.pinned_interaction() + .map_or(0, |state| state.viewport_height) + } + + fn scroll_pinned(&mut self, delta: isize) { + let max = self.max_pinned_scroll(); + let Some(interaction) = self.pinned_interaction_mut() else { + return; + }; + interaction.vertical_scroll = + (interaction.vertical_scroll as isize + delta).clamp(0, max as isize) as u16; + } + + /// The frozen preview's largest valid vertical offset, computed only from its own immutable + /// document and independently measured viewport. + fn max_pinned_scroll(&self) -> u16 { + let Some(document) = self.pinned_document() else { + return 0; + }; + let Some(interaction) = self.pinned_interaction() else { + return 0; + }; + rendered_rows( + interaction, + &document.content().lines, + document.presentation().wrap(), + 0, + ) + .saturating_sub(interaction.viewport_height) + } + + fn scroll_pinned_to_line(&mut self, line_1based: usize) { + let Some(pin) = self.pinned_snapshot.as_mut() else { + return; + }; + let lines = &pin.document.content().lines; + let wrap = pin.document.presentation().wrap(); + let interaction = &mut pin.interaction; + let line = line_1based.max(1).min(lines.len().max(1)); + let offset = if wrap { + wrapped_rows_before(interaction, lines, line - 1, 0) + } else { + line - 1 + }; + let max = + rendered_rows(interaction, lines, wrap, 0).saturating_sub(interaction.viewport_height); + interaction.vertical_scroll = (offset.min(u16::MAX as usize) as u16).min(max); } /// The largest valid scroll offset: total rendered lines minus the viewport height. fn max_content_scroll(&self) -> u16 { self.rendered_line_count() - .saturating_sub(self.content_height) + .saturating_sub(self.active_interaction.viewport_height) + } + + /// Clamp the active interaction's two viewport offsets against the currently displayed lines. + /// The active-only shell supplies the current render's wrap and modal-overlay facts until a + /// later task supplies a Preview Document. + fn clamp_active_interaction(&mut self) { + let wrap = self.effective_wrap(); + let overlay_columns = self.content_overlay_glyph_cols(); + let lines = &self.active_display.content().lines; + clamp_offsets(&mut self.active_interaction, lines, wrap, overlay_columns); } /// How many rows the content occupies once laid out, so the vertical scroll clamps to the @@ -2091,12 +2401,12 @@ impl Controller { /// extra tree walk `effective_wrap` would do. This is the wrapped-aware row total the content /// vertical scrollbar must size/position against — raw `lines.len()` undercounts under wrap. fn rendered_line_count_for(&self, wrap: bool) -> u16 { - let count = if wrap { - self.wrapped_rows_before(self.content.lines.len()) - } else { - self.content.lines.len() - }; - count.min(u16::MAX as usize) as u16 + rendered_rows( + &self.active_interaction, + self.active_lines(), + wrap, + self.content_overlay_glyph_cols(), + ) } /// Cumulative display rows the first `n` content (source) lines occupy at the current content @@ -2105,14 +2415,12 @@ impl Controller { /// the display-row offset of a source line), so the scroll clamp and the go-to-line target are /// computed by the SAME wrapping logic and therefore always agree (AC-3/AC-4). fn wrapped_rows_before(&self, n: usize) -> usize { - let w = self.content_width as usize; - let overlay = self.content_overlay_glyph_cols(); - self.content - .lines - .iter() - .take(n) - .map(|l| crate::text_layout::line_wrapped_rows_prefixed(l, w, overlay)) - .sum::() + wrapped_rows_before( + &self.active_interaction, + self.active_lines(), + n, + self.content_overlay_glyph_cols(), + ) } /// The 0-based display-row offset at which 1-based source `line` begins. Without wrap a source @@ -2137,55 +2445,68 @@ impl Controller { /// override (the copy-line-reference wrap fix). Returns at least 1; the last source line for a /// `row` past the end. `content.lines` empty ⇒ 1 (callers guard the empty case separately). fn line_at_content_row(&self, row: usize) -> usize { - if !self.effective_wrap() { - return row + 1; - } - let w = self.content_width as usize; - let overlay = self.content_overlay_glyph_cols(); - let mut acc = 0usize; - for (i, line) in self.content.lines.iter().enumerate() { - acc += crate::text_layout::line_wrapped_rows_prefixed(line, w, overlay); - if row < acc { - return i + 1; - } - } - self.content.lines.len().max(1) - } - - /// Extract the plain-text content of every displayed line (ANSI spans joined, no styling). - /// Used by the incremental search to feed `search::find_matches`; search always - /// operates on the DISPLAYED content, not the source file, so it works identically across - /// every view mode (SyntaxContent, Diff, RenderedMarkdown — AC-13). - fn content_plain_lines(&self) -> Vec { - self.content - .lines - .iter() - .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) - .collect() + line_at_row( + &self.active_interaction, + self.active_lines(), + row, + self.effective_wrap(), + self.content_overlay_glyph_cols(), + ) } /// Scroll the content pane horizontally by `delta` columns, clamped to `[0, max]`. fn scroll_content_h(&mut self, delta: i32) -> Effects { let max = self.max_content_hscroll() as i32; - let next = (self.content_hscroll as i32 + delta).clamp(0, max); - self.content_hscroll = next as u16; + let next = (self.active_interaction.horizontal_scroll as i32 + delta).clamp(0, max); + self.active_interaction.horizontal_scroll = next as u16; Effects::redraw() } + fn scroll_pinned_h(&mut self, delta: i32) -> Effects { + if self.pinned_snapshot.is_none() { + return Effects::noop(); + } + let max = self.max_pinned_hscroll() as i32; + let Some(interaction) = self.pinned_interaction_mut() else { + return Effects::noop(); + }; + interaction.horizontal_scroll = + (interaction.horizontal_scroll as i32 + delta).clamp(0, max) as u16; + Effects::redraw() + } + + /// The frozen preview's largest valid horizontal offset. Wrapping and viewport width belong + /// solely to the snapshot interaction state, so this cannot affect active horizontal scroll. + fn max_pinned_hscroll(&self) -> u16 { + let Some(document) = self.pinned_document() else { + return 0; + }; + let Some(interaction) = self.pinned_interaction() else { + return 0; + }; + let mut clamped = interaction.clone(); + clamped.horizontal_scroll = u16::MAX; + clamp_offsets( + &mut clamped, + &document.content().lines, + document.presentation().wrap(), + 0, + ); + clamped.horizontal_scroll + } + /// The largest valid horizontal offset: the widest content line minus the viewport width. /// Zero while wrapping (no line overflows the pane, so there is nothing to scroll past). fn max_content_hscroll(&self) -> u16 { - if self.effective_wrap() { - return 0; - } - let widest = self - .content - .lines - .iter() - .map(|l| l.width()) - .max() - .unwrap_or(0); - (widest.min(u16::MAX as usize) as u16).saturating_sub(self.content_width) + let mut clamped = self.active_interaction.clone(); + clamped.horizontal_scroll = u16::MAX; + clamp_offsets( + &mut clamped, + self.active_lines(), + self.effective_wrap(), + self.content_overlay_glyph_cols(), + ); + clamped.horizontal_scroll } /// Right (→/l): expand the selected directory when the tree is focused, or scroll the @@ -2194,6 +2515,9 @@ impl Controller { if self.focus == Focus::Content { return self.scroll_content_h(HSCROLL_STEP as i32); } + if self.focus == Focus::Pinned { + return self.scroll_pinned_h(HSCROLL_STEP as i32); + } if let Some(node) = self.tree.selected() && node.kind == NodeKind::Dir { @@ -2209,6 +2533,9 @@ impl Controller { if self.focus == Focus::Content { return self.scroll_content_h(-(HSCROLL_STEP as i32)); } + if self.focus == Focus::Pinned { + return self.scroll_pinned_h(-(HSCROLL_STEP as i32)); + } if let Some(node) = self.tree.selected() && node.kind == NodeKind::Dir { @@ -2515,23 +2842,34 @@ impl Controller { } } - /// Copy the selected node's path to the clipboard (`y` repo-relative, `Y` absolute). Works - /// for files and directories alike — copying a path mutates nothing (AC-N3). The outcome is - /// surfaced as a transient notice ("Copied …" / a clipboard-failure message). Inert when - /// nothing is selected. The repo-relative form falls back to the absolute path for a node - /// outside the tree root (there is no relative form to give), which in practice cannot - /// happen since every node is under the root. + /// Copy the selected node's path to the clipboard (`y` repo-relative, `Y` absolute), or the + /// captured origin of a focused pinned preview. Works for files and directories alike — + /// copying a path mutates nothing (AC-N3). The outcome is surfaced as a transient notice + /// ("Copied …" / a clipboard-failure message). Inert when nothing is selected. The + /// repo-relative form falls back to the absolute path for a node outside the tree root (there + /// is no relative form to give), which in practice cannot happen since every node is under + /// the root. fn copy_path(&mut self, kind: PathKind) -> Effects { - let Some(node) = self.tree.selected() else { - return Effects::noop(); - }; - let raw = match kind { - PathKind::Absolute => node.path.to_string_lossy().into_owned(), - PathKind::Repo => self - .rel(&node.path) - .unwrap_or_else(|| node.path.clone()) - .to_string_lossy() - .into_owned(), + let raw = if self.focus == Focus::Pinned { + let Some(origin) = self.pinned_document().map(PreviewDocument::origin) else { + return Effects::noop(); + }; + match kind { + PathKind::Absolute => origin.absolute_path().to_string_lossy().into_owned(), + PathKind::Repo => origin.root_relative_path().to_string_lossy().into_owned(), + } + } else { + let Some(node) = self.tree.selected() else { + return Effects::noop(); + }; + match kind { + PathKind::Absolute => node.path.to_string_lossy().into_owned(), + PathKind::Repo => self + .rel(&node.path) + .unwrap_or_else(|| node.path.clone()) + .to_string_lossy() + .into_owned(), + } }; // A filename is untrusted — an attacker can craft one in a browsed repo, and a path may // legally contain control bytes: ESC/BEL (a terminal escape, e.g. a forged OSC 52) or a @@ -2553,13 +2891,16 @@ impl Controller { // pinned to the content pane (entering zoom set it there). Without this guard, Tab would // move focus to the invisible tree and route j/k to its cursor — silently re-rendering a // different file behind the full-screen content. - if self.zoomed { + if self.zoomed && self.pinned_snapshot.is_none() { return Effects::noop(); } - self.focus = match self.focus { - Focus::Tree => Focus::Content, - Focus::Content => Focus::Tree, - }; + // A held pin may be omitted at the current width. Only a drawn pinned viewport belongs in + // the focus cycle, so Tab cannot strand input on an invisible region (AC-17). + let pinned_visible = self + .pinned_snapshot + .as_ref() + .is_some_and(|_| self.pinned_viewport_height() > 0); + self.focus = next_focus(self.focus, pinned_visible, self.zoomed); Effects::redraw() } @@ -2570,7 +2911,11 @@ impl Controller { fn toggle_zoom(&mut self) -> Effects { self.zoomed = !self.zoomed; if self.zoomed { - self.focus = Focus::Content; + self.focus = if self.focus == Focus::Pinned && self.pinned_snapshot.is_some() { + Focus::Pinned + } else { + Focus::Content + }; } else { self.focus = Focus::Tree; // `z` fully exits full-screen: if the viewer had host-zoomed the pane (via `Z`), release @@ -2586,7 +2931,7 @@ impl Controller { fn close_or_unzoom(&mut self) -> Effects { // Esc drops an ambient selection first — the outermost layer of the Esc stack, ahead of // search / unzoom / quit. (A collapsed selection held mid-press is swallowed too; harmless.) - if self.content_selection.take().is_some() { + if self.active_interaction.selection.take().is_some() { return Effects::redraw(); } // Same layer: dismiss a launch open-range flash without quitting. @@ -2595,8 +2940,7 @@ impl Controller { } // A committed search (prompt closed, highlights persisting) is dismissed first — Esc/q // "come out of the search" before they unzoom or close (layered like unzoom). (owner UX) - if self.search.is_some() && !self.prompt_open() { - self.search = None; + if !self.prompt_open() && self.clear_visible_search() { return Effects::redraw(); } if self.zoomed { @@ -2622,6 +2966,39 @@ impl Controller { } } + fn clear_focused_search(&mut self) -> bool { + match self.focus_preview_target() { + PreviewTarget::Active => self.active_interaction.search.take().is_some(), + PreviewTarget::Pinned => self + .pinned_interaction_mut() + .and_then(|interaction| interaction.search.take()) + .is_some(), + } + } + + /// The close layer's search dismissal: the focused preview's search first, then the *other* + /// preview's — but only while that pane is on screen. Esc/q must never quit past highlights + /// the user can still see, while a hidden pane's stale search must not demand an extra + /// keypress that visibly changes nothing. "On screen" is the zero-viewport hidden-pane + /// convention both previews maintain. + fn clear_visible_search(&mut self) -> bool { + if self.clear_focused_search() { + return true; + } + match self.focus_preview_target() { + PreviewTarget::Active => self + .pinned_snapshot + .as_mut() + .filter(|pin| pin.interaction.viewport_width > 0) + .and_then(|pin| pin.interaction.search.take()) + .is_some(), + PreviewTarget::Pinned => { + self.active_interaction.viewport_width > 0 + && self.active_interaction.search.take().is_some() + } + } + } + /// Quit, releasing any host pane zoom the viewer opened so it does not outlive the viewer. fn quit_now(&mut self) -> Effects { self.modal = Modal::None; @@ -2721,6 +3098,19 @@ impl Controller { Effects::redraw() } + /// Move only the divider between the frozen and active previews. A missing or currently + /// undrawn pin leaves every session field unchanged, including the retained ratio, so resizing + /// cannot change a divider the user cannot see (AC-22). + fn resize_preview_split(&mut self, delta: i16) -> Effects { + if self.pinned_snapshot.is_none() || self.pinned_viewport_height() == 0 { + return Effects::noop(); + } + self.preview_split_pct = (self.preview_split_pct as i16 + delta) + .clamp(PREVIEW_SPLIT_MIN as i16, PREVIEW_SPLIT_MAX as i16) + as u16; + Effects::redraw() + } + /// First-manual-resize seam: mark the split user-controlled (which lifts the `tree_max_cols` /// cap) and, so the tree doesn't jump on that first keypress/drag, seed `split_pct` from the /// width the tree is *currently displayed* at (which may be capped below `split_pct`%). A no-op @@ -2767,9 +3157,18 @@ impl Controller { /// natural width for horizontal scroll), so the content itself is re-rendered — preserving /// scroll and search, like a resize reflow. The scroll clamp recomputes from the new layout. fn toggle_wrap(&mut self) -> Effects { - self.wrap_override = Some(!self.effective_wrap()); - self.content_scroll = self.content_scroll.min(self.max_content_scroll()); - self.content_hscroll = self.content_hscroll.min(self.max_content_hscroll()); + let wrap = !self.effective_wrap(); + self.wrap_override = Some(wrap); + if let Some(current) = self.active_display.document() { + let presentation = PreviewPresentation::new( + current.presentation().view_mode(), + wrap, + current.presentation().pad_left(), + ); + self.active_display + .replace_document_presentation(presentation); + } + self.clamp_active_interaction(); // Markdown must re-render at the new wrap width (fit vs. natural); a no-op for other modes, // which only need the re-clamp above. self.rerender_after_wrap_toggle(); @@ -2874,7 +3273,7 @@ impl Controller { /// has been typed into yet, or the prompt was closed and state cleared). Exposed for tests; /// the Presenter reads it for the highlight overlay. pub fn search(&self) -> Option<&SearchState> { - self.search.as_ref() + self.active_interaction.search.as_ref() } /// The full candidate list loaded when the finder was opened, or an empty slice when @@ -2915,7 +3314,8 @@ impl Controller { /// natural-width layout and the pane scrolls to reveal the whole table, and `None` before the /// first draw has measured the pane (`content_width == 0`). Only the markdown delegate uses it. fn md_wrap_width(&self) -> Option { - (self.content_width > 0 && self.effective_wrap()).then_some(self.content_width) + (self.active_interaction.viewport_width > 0 && self.effective_wrap()) + .then_some(self.active_interaction.viewport_width) } /// The content pane's drawable text width, unconditional (unlike @@ -2923,7 +3323,8 @@ impl Controller { /// preference) — feeds `delta`'s own `--width` for the diff modes. `None` before the /// first draw has measured the pane. fn pane_width(&self) -> Option { - (self.content_width > 0).then_some(self.content_width) + (self.active_interaction.viewport_width > 0) + .then_some(self.active_interaction.viewport_width) } /// `w` wrap toggle: re-render if the selection is rendered markdown, since wrap changes @@ -2994,6 +3395,9 @@ impl Controller { // never receive a result for this seq, which is fine (nothing was cleared). let _ = self.job_tx.send(RenderJob { seq, + root: self.root.clone(), + branch: self.captured_branch(), + presentation: self.preview_presentation(mode), path, rel, mode, @@ -3016,8 +3420,8 @@ impl Controller { let seq = self.latest_seq; // A fresh render means new content — start it at the top-left, never inheriting the // previous file's scroll offsets. - self.content_scroll = 0; - self.content_hscroll = 0; + self.active_interaction.vertical_scroll = 0; + self.active_interaction.horizontal_scroll = 0; // A new render supersedes any queued go-to-line jump from an OLDER render (e.g. the user // navigated away before an auto-switch render landed). The auto-switch path sets its own // `pending_goto` AFTER calling this, so its jump survives; only stale ones are cleared. @@ -3032,11 +3436,11 @@ impl Controller { // `refresh_search` (the incremental-typing path) only calls `scroll_to_line` and sets // `self.search` directly — it does NOT call `dispatch_render` — so live typing is NOT // wiped by this clear. - self.search = None; + self.active_interaction.search = None; // Drop an ambient selection on any content change: its line/char coordinates only mean // something against the body it was dragged over, so a stale highlight (and copy) must not // carry onto new content. Scrolling keeps it — it doesn't dispatch, and the coords stay valid. - self.content_selection = None; + self.active_interaction.selection = None; // Launch open-range flash is also content-bound: a new file/view must not keep the old // range painted. (apply_open_target re-arms it after its own dispatch.) self.open_range_flash = None; @@ -3080,6 +3484,9 @@ impl Controller { .job_tx .send(RenderJob { seq, + root: self.root.clone(), + branch: self.captured_branch(), + presentation: self.preview_presentation(mode), path: node.path, rel, mode, @@ -3092,10 +3499,15 @@ impl Controller { }) .is_ok() { - self.content = Text::raw("Rendering\u{2026}"); - self.content_notices.clear(); - self.content_source = None; // the placeholder has no source; the landing render brings its own - self.content_rendering = true; + let previous_title = self.active_display.title(); + let previous_presentation = self.active_display.presentation().copied(); + let previous_origin = self.active_display.displayed_origin().cloned(); + self.active_display = ActiveDisplay::Loading { + content: Arc::new(Text::raw("Rendering\u{2026}")), + previous_title, + previous_presentation, + previous_origin, + }; } } @@ -3104,14 +3516,14 @@ impl Controller { /// tree. The strings are static and first-party, so they need no AC-27 sanitization (they /// carry no control bytes); they flow through the same content path the renderer uses. fn clear_content(&mut self, reason: EmptyReason) { - self.content = Text::raw(reason.label()); - self.content_notices.clear(); - self.content_source = None; // guidance text has no source behind it - // No file content is displayed for a directory/empty tree, and no render is in flight - // (this path sends no `RenderJob`), so the title falls back to the selected node's name - //. - self.content_path = None; - self.content_rendering = false; + self.active_display = match reason { + EmptyReason::Directory => ActiveDisplay::Directory { + content: Arc::new(Text::raw(reason.label())), + notices: Vec::new(), + presentation: PreviewPresentation::new(ViewMode::SyntaxContent, false, false), + }, + EmptyReason::NoFiles => ActiveDisplay::EmptyTree(Arc::new(Text::raw(reason.label()))), + }; } /// Drain finished renders from the worker, applying only the one matching the latest @@ -3119,7 +3531,9 @@ impl Controller { /// fresh content was applied, so the run loop repaints; `None` when nothing arrived. pub fn poll(&mut self) -> Option { let mut applied = false; - while let Ok((seq, result)) = self.result_rx.try_recv() { + while let Ok(completion) = self.result_rx.try_recv() { + let RenderCompletion { job, result } = completion; + let seq = job.seq; if seq == self.latest_seq { // A width-reflow re-render (a resize, not a selection change): its content replaces // the current body, but scroll and search must survive — the user did not navigate. @@ -3127,30 +3541,41 @@ impl Controller { // reflow (its seq won't match a stale value anyway, but keep the flag tight). let is_reflow = self.reflow_seq == Some(seq); self.reflow_seq = None; - self.content = result.content; + let display = if job.directory_diff { + ActiveDisplay::Directory { + content: Arc::new(result.content), + notices: result.notices, + presentation: job.presentation, + } + } else { + let root_relative_path = job.rel.clone().unwrap_or_else(|| { + job.path + .strip_prefix(&job.root) + .unwrap_or(&job.path) + .to_path_buf() + }); + ActiveDisplay::Document(PreviewDocument::new( + result.content, + result.notices, + result.source, + job.presentation, + PreviewOrigin::new(job.root, job.branch, job.path, root_relative_path), + )) + }; + self.active_display = display; // Covers the placeholder→land window: a selection dragged over "Rendering…" after // dispatch_render's clear must not carry its stale coordinates onto the new body. // (Also dropped on a reflow: an ambient selection's line/col coords are against the // pre-reflow rendered lines, so they no longer point at the same text.) - self.content_selection = None; - self.content_notices = result.notices; - self.content_source = result.source; // in lockstep with `content` (copy fidelity) + self.active_interaction.selection = None; self.applied_seq = seq; // the displayed content is now this render (go-to-line guard) // A reflow keeps the user's scroll position, but the reflowed body may have a // different rendered-row count (a table re-lays-out at the new width), so re-clamp // the offset to the new content. A selection-change render already reset scroll to 0 // in dispatch_render, so this is gated to the reflow path. if is_reflow { - self.content_scroll = self.content_scroll.min(self.max_content_scroll()); + self.clamp_active_interaction(); } - // the body has landed — now switch the title to match it. The latest - // dispatched render always corresponds to the current tree selection (every - // selection change calls `dispatch_render`), so the applied result's file is the - // selected node. A stale result for a superseded selection was dropped above by - // the `seq == latest_seq` guard, so this never points `content_path` at a file - // the user has already moved past. The render is no longer in flight. - self.content_path = self.tree.selected().map(|n| n.path.clone()); - self.content_rendering = false; applied = true; // A queued go-to-line jump (auto-switch from a transformed view, AC-7) applies once // ITS render lands: now that the source-mapped content is in, scroll to the line. @@ -3171,12 +3596,12 @@ impl Controller { if let Some(pseq) = self.pending_line_select && pseq == seq { - if !self.content.lines.is_empty() { - let last = self.content.lines.len(); + if !self.active_lines().is_empty() { + let last = self.active_lines().len(); let top = self - .line_at_content_row(self.content_scroll as usize) + .line_at_content_row(self.active_interaction.vertical_scroll as usize) .clamp(1, last); - self.modal = Modal::LineSelect(LineSelectState::new(top)); + self.modal = Modal::LineSelect(PreviewSelection::new(top)); } self.pending_line_select = None; } @@ -3186,12 +3611,22 @@ impl Controller { // the new content, else drop a committed search — UNLESS this was a width reflow, // where a committed search must be recomputed (not dropped), so a resize does not // silently clear the user's active highlighting. - if self.modal.prompt().map(|p| p.mode) == Some(crate::infile::PromptMode::Search) { + // + // An open Search prompt only speaks for the ACTIVE side when it targets the active + // side: `refresh_search` routes by `prompt_target`, so a prompt opened on the + // pinned preview would recompute the (frozen, unchanged) pinned matches and leave + // the active side's committed matches pointing at pre-render rendered lines — wrong + // highlights, and `n`/`N` from Content focus jumping to stale rows. When the prompt + // belongs to the pinned side, the active side takes the promptless rule instead: + // recompute on a reflow, drop on a selection-change render. + if self.modal.prompt().map(|p| p.mode) == Some(crate::infile::PromptMode::Search) + && self.prompt_target() == PreviewTarget::Active + { self.refresh_search(); } else if is_reflow { self.recompute_committed_search(); } else { - self.search = None; + self.active_interaction.search = None; } } // else: a superseded selection's render — drop it. @@ -3300,13 +3735,23 @@ pub(super) enum ClickOrigin { enum MouseRegion { TreeRow(usize), Content, + /// The frozen reference preview's text interior. Input here must never mutate the active + /// preview's interaction state. + Pinned, /// The content column's top-border title (filename). Double-click toggles zoom (#106). ContentTitle, + /// The pinned preview identity title. Unlike `ContentTitle`, it only focuses the reference + /// preview; active-only double-click behavior is unavailable from a pin. + PinnedTitle, Divider, + /// The divider between pinned and active previews (separate from the existing tree divider). + PreviewDivider, /// The content pane's vertical scrollbar — drag up/down to scroll. ContentVBar, /// The content pane's horizontal scrollbar — drag left/right to scroll. ContentHBar, + PinnedVBar, + PinnedHBar, /// The tree's vertical scrollbar — drag up/down to scrub the selection through the list. TreeVBar, /// The tree's horizontal scrollbar — drag left/right to scroll the tree sideways. @@ -3320,8 +3765,11 @@ enum MouseRegion { #[derive(Clone, Copy, PartialEq, Eq)] enum Drag { Divider, + PreviewDivider, ContentV, ContentH, + PinnedV, + PinnedH, TreeV, TreeH, /// Dragging the finder overlay's vertical scrollbar (handled in `handle_finder_mouse`). @@ -3496,6 +3944,53 @@ mod tests { assert_eq!(ctrl.page_step(), 7, "and it tracks a resize"); } + #[test] + fn scroll_pinned_to_line_uses_the_pinned_viewport_width_for_wrapped_rows() { + let mut ctrl = wiring_controller(); + ctrl.pinned_snapshot = Some(PinnedSnapshot { + document: PreviewDocument::new( + Text::raw( + [ + "x".repeat(25), // 3 rows at the pinned width of 10 + "y".repeat(11), // 2 more rows + "target".into(), // source line 3 starts at display row 5 + "tail".into(), + "tail".into(), + "tail".into(), + ] + .join("\n"), + ), + Vec::new(), + None, + PreviewPresentation::new(ViewMode::SyntaxContent, true, false), + PreviewOrigin::new( + PathBuf::from("/worktrees/pinned"), + BranchState::Named("pinned".into()), + PathBuf::from("/worktrees/pinned/file.rs"), + PathBuf::from("file.rs"), + ), + ), + interaction: PreviewInteractionState { + viewport_width: 10, + viewport_height: 2, + ..PreviewInteractionState::default() + }, + }); + + ctrl.scroll_pinned_to_line(3); + + assert_eq!( + ctrl.pinned_interaction().map(|state| state.vertical_scroll), + Some(5), + "source line 3 follows the 3 + 2 wrapped rows before it" + ); + assert_ne!( + ctrl.pinned_interaction().map(|state| state.vertical_scroll), + Some(2), + "the pinned source line must not use a naive line-minus-one offset" + ); + } + struct ChangedGit { path: PathBuf, } @@ -3627,7 +4122,7 @@ mod tests { assert_eq!(ctrl.open_range_flash_lines(), Some((1, 3))); // Passive highlight appears in view_state (not a modal). let vs = ctrl.view_state(); - let ls = vs.line_select.expect("passive range flash in view"); + let ls = vs.active.line_select.expect("passive range flash in view"); assert!(ls.passive); assert_eq!((ls.start, ls.end), (1, 3)); assert!( diff --git a/src/controller/mouse.rs b/src/controller/mouse.rs index ccd74dd4..c0029652 100644 --- a/src/controller/mouse.rs +++ b/src/controller/mouse.rs @@ -54,7 +54,7 @@ impl Controller { return Effects::noop(); } let (col, row) = (ev.column, ev.row); - let last = self.content.lines.len().max(1); + let last = self.active_lines().len().max(1); match ev.kind { MouseEventKind::Down(MouseButton::Left) => { // A press outside the content region is inert and drops any in-flight drag. @@ -141,23 +141,42 @@ impl Controller { // But not over the "Rendering…" placeholder: a file *is* selected mid-render, so // the is-file guard in `copy_content_selection` wouldn't stop a drag from copying // the placeholder text. (The press still focuses the pane.) - if self.content_rendering { + if self.active_display.is_loading() { return Effects::redraw(); } let (line, caret) = self.char_at_content_col(col, row); - let last = self.content.lines.len().max(1); - let mut sel = LineSelectState::new(line); + let last = self.active_lines().len().max(1); + let mut sel = PreviewSelection::new(line); sel.begin_char(line, caret, last); - self.content_selection = Some(sel); + self.active_interaction.selection = Some(sel); self.drag = Some(Drag::ContentSelect); return Effects::redraw(); } - // A press anywhere outside the content region drops a standing ambient selection. - let had_selection = self.content_selection.take().is_some(); + if matches!(region, MouseRegion::Pinned | MouseRegion::PinnedTitle) { + // A pinned snapshot is scrollable and focusable, but it deliberately has no + // text-selection/copy path. Treat a press as focus only and leave the active + // preview's standing selection intact. + self.last_click = None; + self.focus = Focus::Pinned; + self.drag = None; + return Effects::redraw(); + } + // A press anywhere outside the content region drops a standing ambient selection, + // except a pinned scrollbar: it only controls the frozen preview and must not + // dismiss a selection belonging to the active preview. + let had_selection = + if matches!(region, MouseRegion::PinnedVBar | MouseRegion::PinnedHBar) { + false + } else { + self.active_interaction.selection.take().is_some() + }; self.drag = match region { MouseRegion::Divider => Some(Drag::Divider), + MouseRegion::PreviewDivider => Some(Drag::PreviewDivider), MouseRegion::ContentVBar => Some(Drag::ContentV), MouseRegion::ContentHBar => Some(Drag::ContentH), + MouseRegion::PinnedVBar => Some(Drag::PinnedV), + MouseRegion::PinnedHBar => Some(Drag::PinnedH), MouseRegion::TreeVBar => Some(Drag::TreeV), MouseRegion::TreeHBar => Some(Drag::TreeH), _ => None, @@ -165,6 +184,8 @@ impl Controller { let fx = match region { MouseRegion::ContentVBar => self.scroll_content_to_row(row), MouseRegion::ContentHBar => self.scroll_content_h_to_col(col), + MouseRegion::PinnedVBar => self.scroll_pinned_to_row(row), + MouseRegion::PinnedHBar => self.scroll_pinned_h_to_col(col), MouseRegion::TreeVBar => self.scroll_tree_to_row(row), MouseRegion::TreeHBar => self.scroll_tree_h_to_col(col), _ => Effects::noop(), @@ -174,8 +195,11 @@ impl Controller { } MouseEventKind::Drag(MouseButton::Left) => match self.drag { Some(Drag::Divider) => self.resize_split_to_col(col), + Some(Drag::PreviewDivider) => self.resize_preview_split_to_col(col), Some(Drag::ContentV) => self.scroll_content_to_row(row), Some(Drag::ContentH) => self.scroll_content_h_to_col(col), + Some(Drag::PinnedV) => self.scroll_pinned_to_row(row), + Some(Drag::PinnedH) => self.scroll_pinned_h_to_col(col), Some(Drag::TreeV) => self.scroll_tree_to_row(row), Some(Drag::TreeH) => self.scroll_tree_h_to_col(col), // The finder is modal: its scrollbar drag is handled in handle_finder_mouse and @@ -185,8 +209,8 @@ impl Controller { // — the L-mode drag, but on the Modal-independent `content_selection`. Some(Drag::ContentSelect) => { let (line, caret) = self.char_at_content_col(col, row); - let last = self.content.lines.len().max(1); - if let Some(sel) = self.content_selection.as_mut() { + let last = self.active_lines().len().max(1); + if let Some(sel) = self.active_interaction.selection.as_mut() { sel.drag_char(line, caret, last); } self.autoscroll_selection(row); @@ -200,7 +224,8 @@ impl Controller { // keeping the highlight. Some(Drag::ContentSelect) => { let collapsed = self - .content_selection + .active_interaction + .selection .as_ref() .map(|s| { let (a, b) = s.char_span(); @@ -209,7 +234,7 @@ impl Controller { .unwrap_or(true); self.last_click = None; if collapsed { - self.content_selection = None; + self.active_interaction.selection = None; self.focus = Focus::Content; Effects::redraw() } else { @@ -277,10 +302,18 @@ impl Controller { self.focus = Focus::Content; Effects::redraw() } + MouseRegion::Pinned | MouseRegion::PinnedTitle => { + self.last_click = None; + self.focus = Focus::Pinned; + Effects::redraw() + } // Scrollbars are handled on press/drag (above), not as a click; reaching here is inert. MouseRegion::Divider + | MouseRegion::PreviewDivider | MouseRegion::ContentVBar | MouseRegion::ContentHBar + | MouseRegion::PinnedVBar + | MouseRegion::PinnedHBar | MouseRegion::TreeVBar | MouseRegion::TreeHBar | MouseRegion::Outside => { @@ -298,6 +331,10 @@ impl Controller { self.scroll_content(delta); Effects::redraw() } + MouseRegion::Pinned => { + self.scroll_pinned(delta); + Effects::redraw() + } MouseRegion::TreeRow(_) => { self.focus = Focus::Tree; self.tree.move_cursor(delta.signum()); @@ -314,6 +351,7 @@ impl Controller { fn hscroll_at(&mut self, col: u16, row: u16, delta: i32) -> Effects { match self.hit_test(col, row) { MouseRegion::Content => self.scroll_content_h(delta), + MouseRegion::Pinned => self.scroll_pinned_h(delta), MouseRegion::TreeRow(_) => self.scroll_tree_h(delta), _ => Effects::noop(), } @@ -378,7 +416,7 @@ impl Controller { else { return Effects::noop(); }; - self.content_scroll = off as u16; + self.active_interaction.vertical_scroll = off as u16; Effects::redraw() } @@ -392,10 +430,48 @@ impl Controller { else { return Effects::noop(); }; - self.content_hscroll = off as u16; + self.active_interaction.horizontal_scroll = off as u16; Effects::redraw() } + /// Map a vertical press/drag on the frozen pinned preview's scrollbar to only its viewport + /// offset. The active preview's `content_*` geometry and interaction never participate. + fn scroll_pinned_to_row(&mut self, row: u16) -> Effects { + let Some(track) = self.geom.pinned_vbar else { + return Effects::noop(); + }; + let Some(off) = + Self::track_to_offset(row, track.y, track.height, self.max_pinned_scroll() as u32) + else { + return Effects::noop(); + }; + if let Some(interaction) = self.pinned_interaction_mut() { + interaction.vertical_scroll = off as u16; + Effects::redraw() + } else { + Effects::noop() + } + } + + /// Map a horizontal press/drag on the frozen pinned preview's scrollbar to only its viewport + /// offset. + fn scroll_pinned_h_to_col(&mut self, col: u16) -> Effects { + let Some(track) = self.geom.pinned_hbar else { + return Effects::noop(); + }; + let Some(off) = + Self::track_to_offset(col, track.x, track.width, self.max_pinned_hscroll() as u32) + else { + return Effects::noop(); + }; + if let Some(interaction) = self.pinned_interaction_mut() { + interaction.horizontal_scroll = off as u16; + Effects::redraw() + } else { + Effects::noop() + } + } + /// Map a horizontal press/drag on the tree's horizontal scrollbar to a tree h-scroll offset. fn scroll_tree_h_to_col(&mut self, col: u16) -> Effects { let Some(track) = self.geom.tree_hbar else { @@ -463,6 +539,21 @@ impl Controller { Effects::redraw() } + /// During a pinned/active preview-divider drag, set only the frozen preview's share from the + /// measured preview area. This intentionally does not engage or modify the tree split: that + /// divider remains owned by [`resize_split_to_col`](Self::resize_split_to_col). + fn resize_preview_split_to_col(&mut self, col: u16) -> Effects { + if self.pinned_snapshot.is_none() || self.geom.preview_area_width == 0 { + return Effects::noop(); + } + let right_edge = self.geom.preview_area_x as u32 + self.geom.preview_area_width as u32; + let pinned_width = right_edge.saturating_sub(col as u32); + self.preview_split_pct = (pinned_width * 100 / self.geom.preview_area_width as u32) + .clamp(PREVIEW_SPLIT_MIN as u32, PREVIEW_SPLIT_MAX as u32) + as u16; + Effects::redraw() + } + /// Which region of the last-drawn frame a cell falls in. The divider is checked first (it /// sits between the columns); a tree click maps to a visible node index by its row. fn hit_test(&self, col: u16, row: u16) -> MouseRegion { @@ -471,6 +562,11 @@ impl Controller { { return MouseRegion::Divider; } + if let Some(dx) = self.geom.preview_divider_x + && (col == dx || col + 1 == dx) + { + return MouseRegion::PreviewDivider; + } // Scrollbars live INSIDE the panes (a reserved gutter), fed back as 1-cell track rects that // are present only when that bar is drawn — so a hit on a `Some` track is a real bar. Check // them before the text rects. The tree's vertical bar no longer shares the divider column. @@ -481,6 +577,12 @@ impl Controller { if self.geom.content_hbar.is_some_and(|r| r.contains(pos)) { return MouseRegion::ContentHBar; } + if self.geom.pinned_vbar.is_some_and(|r| r.contains(pos)) { + return MouseRegion::PinnedVBar; + } + if self.geom.pinned_hbar.is_some_and(|r| r.contains(pos)) { + return MouseRegion::PinnedHBar; + } if self.geom.tree_vbar.is_some_and(|r| r.contains(pos)) { return MouseRegion::TreeVBar; } @@ -505,11 +607,19 @@ impl Controller { { return MouseRegion::ContentTitle; } + if self.geom.pinned_title_rect.is_some_and(|r| r.contains(pos)) { + return MouseRegion::PinnedTitle; + } if let Some(c) = self.geom.content_inner && c.contains(Position { x: col, y: row }) { return MouseRegion::Content; } + if let Some(pinned) = self.geom.pinned_inner + && pinned.contains(Position { x: col, y: row }) + { + return MouseRegion::Pinned; + } MouseRegion::Outside } } diff --git a/src/controller/pinned.rs b/src/controller/pinned.rs new file mode 100644 index 00000000..247d1996 --- /dev/null +++ b/src/controller/pinned.rs @@ -0,0 +1,146 @@ +//! One frozen, in-memory preview snapshot and its lifecycle operation. + +use super::*; + +fn branch_label(branch: &crate::preview::BranchState) -> &str { + match branch { + crate::preview::BranchState::Named(name) => name, + crate::preview::BranchState::Detached => "detached", + } +} + +/// The one optional reference preview retained for this controller session. +/// +/// The document is an applied render only; the interaction state starts as a clone of the active +/// preview and subsequently receives only pinned-viewport measurements. Neither side shares +/// mutable state with the active preview. +pub(super) struct PinnedSnapshot { + pub(super) document: PreviewDocument, + pub(super) interaction: PreviewInteractionState, +} + +impl Controller { + pub(super) fn pinned_interaction(&self) -> Option<&PreviewInteractionState> { + self.pinned_snapshot.as_ref().map(|pin| &pin.interaction) + } + + pub(super) fn pinned_interaction_mut(&mut self) -> Option<&mut PreviewInteractionState> { + self.pinned_snapshot + .as_mut() + .map(|pin| &mut pin.interaction) + } + + pub(super) fn pinned_document(&self) -> Option<&PreviewDocument> { + self.pinned_snapshot.as_ref().map(|pin| &pin.document) + } + + /// Create, replace, remove, or reject the one in-memory pinned snapshot. + /// + /// This is intentionally a controller seam rather than an input action: T-13 owns key + /// wiring. It never starts a render because a pin is a clone of the already-applied document. + pub fn pin_active_preview(&mut self) -> Effects { + let Some(document) = self.active_document().cloned() else { + self.action_notice = Some( + match self.active_display { + ActiveDisplay::Loading { .. } => "Cannot pin while preview is rendering", + ActiveDisplay::Directory { .. } => "Cannot pin a directory", + ActiveDisplay::EmptyTree(_) => "Cannot pin: no file is selected", + ActiveDisplay::Document(_) => unreachable!("checked above"), + } + .into(), + ); + return Effects::redraw(); + }; + + self.action_notice = None; + if self + .pinned_snapshot + .as_ref() + .is_some_and(|pin| pin.document.origin().identity() == document.origin().identity()) + { + self.pinned_snapshot = None; + if self.focus == Focus::Pinned { + self.focus = Focus::Content; + } + } else { + let branch_change = self.pinned_snapshot.as_ref().and_then(|pin| { + let old = pin.document.origin(); + let new = document.origin(); + (old.root() == new.root() + && old.root_relative_path() == new.root_relative_path() + && old.branch() != new.branch()) + .then(|| { + ( + branch_label(old.branch()).to_owned(), + branch_label(new.branch()).to_owned(), + ) + }) + }); + self.pinned_snapshot = Some(PinnedSnapshot { + document, + interaction: self.active_interaction.clone(), + }); + if let Some((old, new)) = branch_change { + self.action_notice = Some(format!("Replaced pin (branch changed: {old} → {new})")); + } + } + Effects::redraw() + } + + /// Project the frozen snapshot for the shared Presenter path. + pub(super) fn pinned_projection(&self) -> Option { + let pin = self.pinned_snapshot.as_ref()?; + let presentation = pin.document.presentation(); + Some(PreviewProjection { + content: pin.document.shared_content(), + notices: pin.document.notices().to_vec(), + flash: None, + title: None, + rendering: false, + scroll: pin.interaction.vertical_scroll, + hscroll: pin.interaction.horizontal_scroll, + rows: rendered_rows( + &pin.interaction, + &pin.document.content().lines, + presentation.wrap(), + 0, + ), + wrap: presentation.wrap(), + pad_left: presentation.pad_left(), + search: pin.interaction.search.as_ref().map(|search| ContentSearch { + matches: search.matches.clone(), + current: search.current, + }), + line_select: None, + selection: None, + origin: Some(pin.document.origin().clone()), + }) + } + + /// Record the pinned preview's independently measured viewport without triggering work. + /// + /// `None` means the structural layout did not draw the pinned region, so mirror the active + /// preview's hidden-pane convention with a zero-sized viewport instead of retaining stale + /// wide-layout dimensions. + pub(super) fn set_pinned_viewport(&mut self, viewport: Option<(u16, u16)>) { + let (width, height) = viewport.unwrap_or_default(); + if viewport.is_none() && self.focus == Focus::Pinned { + // AC-17: a pin omitted for space cannot retain focus after the frame that hid it. + self.focus = Focus::Content; + } + let Some(pin) = self.pinned_snapshot.as_mut() else { + return; + }; + if width == pin.interaction.viewport_width && height == pin.interaction.viewport_height { + return; // unchanged — avoid re-walking the frozen document on every idle draw + } + pin.interaction.viewport_width = width; + pin.interaction.viewport_height = height; + clamp_offsets( + &mut pin.interaction, + &pin.document.content().lines, + pin.document.presentation().wrap(), + 0, + ); + } +} diff --git a/src/focus_policy.rs b/src/focus_policy.rs new file mode 100644 index 00000000..c86984fb --- /dev/null +++ b/src/focus_policy.rs @@ -0,0 +1,133 @@ +//! Pure focus cycling and pinned-preview action policy. + +use crate::intent::Intent; +use crate::presenter::Focus; + +/// Destination selected by the focus/action policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ActionTarget { + Tree, + Active, + Pinned, + Rejected, +} + +/// The mutable preview interaction state an in-file action targets. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PreviewTarget { + Active, + Pinned, +} + +/// Cycle keyboard focus through the regions that are currently meaningful. +pub fn next_focus(current: Focus, has_pin: bool, tree_hidden: bool) -> Focus { + if !has_pin { + return if tree_hidden { + Focus::Content + } else { + match current { + Focus::Tree => Focus::Content, + Focus::Content | Focus::Pinned => Focus::Tree, + } + }; + } + + if tree_hidden { + return match current { + Focus::Pinned => Focus::Content, + Focus::Tree | Focus::Content => Focus::Pinned, + }; + } + + match current { + Focus::Tree => Focus::Content, + Focus::Content => Focus::Pinned, + Focus::Pinned => Focus::Tree, + } +} + +/// Decide whether an action is unavailable from pinned focus. Rejection is deliberately exact: +/// callers consume it with a notice and must never fall through to the active file. +pub fn target_for(focus: Focus, intent: Intent) -> ActionTarget { + if focus == Focus::Pinned && unavailable_from_pinned(intent) { + return ActionTarget::Rejected; + } + match focus { + Focus::Tree => ActionTarget::Tree, + Focus::Content => ActionTarget::Active, + Focus::Pinned => ActionTarget::Pinned, + } +} + +/// AC-31's complete pinned-only unavailable set. +pub fn unavailable_from_pinned(intent: Intent) -> bool { + matches!( + intent, + Intent::Activate + | Intent::OpenFullscreen + | Intent::OpenGoToLine + | Intent::TreeScrollRight + | Intent::OpenInEditor + | Intent::OpenWithApp + | Intent::RevealInFileManager + | Intent::AddAnnotation + | Intent::ShowAnnotations + | Intent::CycleDiffRender + | Intent::CycleView + | Intent::ToggleWrap + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn focus_cycles_match_visible_regions() { + assert_eq!(next_focus(Focus::Tree, true, false), Focus::Content); + assert_eq!(next_focus(Focus::Content, true, false), Focus::Pinned); + assert_eq!(next_focus(Focus::Pinned, true, false), Focus::Tree); + assert_eq!(next_focus(Focus::Content, true, true), Focus::Pinned); + assert_eq!(next_focus(Focus::Pinned, true, true), Focus::Content); + assert_eq!(next_focus(Focus::Tree, false, false), Focus::Content); + assert_eq!(next_focus(Focus::Content, false, false), Focus::Tree); + } + + #[test] + fn target_for_maps_each_focus_to_its_interaction_surface() { + assert_eq!(target_for(Focus::Tree, Intent::NavDown), ActionTarget::Tree); + assert_eq!( + target_for(Focus::Content, Intent::NavDown), + ActionTarget::Active + ); + assert_eq!( + target_for(Focus::Pinned, Intent::NavDown), + ActionTarget::Pinned + ); + } + + #[test] + fn pinned_rejection_set_is_exact() { + let unavailable = [ + Intent::Activate, + Intent::OpenFullscreen, + Intent::OpenGoToLine, + Intent::TreeScrollRight, + Intent::OpenInEditor, + Intent::OpenWithApp, + Intent::RevealInFileManager, + Intent::AddAnnotation, + Intent::ShowAnnotations, + Intent::CycleDiffRender, + Intent::CycleView, + Intent::ToggleWrap, + ]; + for intent in Intent::ALL { + assert_eq!( + unavailable_from_pinned(intent), + unavailable.contains(&intent), + "{intent:?}" + ); + } + } +} diff --git a/src/help.rs b/src/help.rs index d93d276e..ce020702 100644 --- a/src/help.rs +++ b/src/help.rs @@ -1267,6 +1267,40 @@ mod tests { ); } + #[test] + fn keybindings_text_reports_the_effective_pin_preview_binding() { + // T-13: the generated Help model reads the resolved bindings, not the registry default. + let (bindings, outcome) = resolve_one("pin_preview", "P"); + let text = keybindings_text(input::registry(), &bindings, &outcome); + let row = text + .lines() + .find(|line| line.split_whitespace().next() == Some("pin_preview")) + .expect("Help must contain the pin_preview action"); + assert!(row.split_whitespace().any(|word| word == "P")); + assert!(row.contains("(custom)")); + } + + #[test] + fn keybindings_text_lists_the_pinned_preview_resize_defaults() { + // The generated Help model is sourced from the registry, so the two resize actions remain + // discoverable alongside pin_preview without a second hand-maintained key list. + let text = keybindings_text( + input::registry(), + &input::default_bindings(), + &input::KeyLoadOutcome::default(), + ); + for (name, key) in [("shrink_preview", "{"), ("grow_preview", "}")] { + let row = text + .lines() + .find(|line| line.split_whitespace().next() == Some(name)) + .unwrap_or_else(|| panic!("Help must contain the {name} action")); + assert!( + row.split_whitespace().any(|word| word == key), + "{name} must show its default {key:?}:\n{row}" + ); + } + } + #[test] fn keybindings_text_groups_actions_under_their_category_header() { // AC-19 (grouping): each action renders under its category header, and the headers appear in diff --git a/src/infile.rs b/src/infile.rs index d120c685..4adb124b 100644 --- a/src/infile.rs +++ b/src/infile.rs @@ -19,7 +19,7 @@ pub enum PromptMode { /// /// Fields are `pub` so the controller can read and update them without /// needing extra accessors, and to avoid dead-code warnings before all paths land. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct SearchState { pub query: String, pub matches: Vec, diff --git a/src/input.rs b/src/input.rs index 2c459689..8ce8b040 100644 --- a/src/input.rs +++ b/src/input.rs @@ -391,6 +391,20 @@ pub(crate) const REGISTRY: &[Binding] = &[ description: "Widen the tree column.", category: "View & layout", }, + Binding { + intent: Intent::ShrinkPreview, + name: "shrink_preview", + default_keys: &[KeyCode::Char('{')], + description: "Give the pinned preview less horizontal space.", + category: "View & layout", + }, + Binding { + intent: Intent::GrowPreview, + name: "grow_preview", + default_keys: &[KeyCode::Char('}')], + description: "Give the pinned preview more horizontal space.", + category: "View & layout", + }, Binding { intent: Intent::ToggleWrap, name: "toggle_wrap", @@ -405,6 +419,13 @@ pub(crate) const REGISTRY: &[Binding] = &[ description: "Hide the tree so the content pane fills the frame, or restore the split.", category: "View & layout", }, + Binding { + intent: Intent::PinPreview, + name: "pin_preview", + default_keys: &[KeyCode::Char('p')], + description: "Pin or unpin the settled preview as a reference.", + category: "View & layout", + }, Binding { intent: Intent::Refresh, name: "refresh", @@ -832,8 +853,11 @@ mod tests { (KeyCode::Tab, Intent::ToggleFocus), (KeyCode::Char('<'), Intent::ShrinkTree), (KeyCode::Char('>'), Intent::GrowTree), + (KeyCode::Char('{'), Intent::ShrinkPreview), + (KeyCode::Char('}'), Intent::GrowPreview), (KeyCode::Char('w'), Intent::ToggleWrap), (KeyCode::Char('z'), Intent::ToggleZoom), + (KeyCode::Char('p'), Intent::PinPreview), (KeyCode::Char('r'), Intent::Refresh), (KeyCode::Char('u'), Intent::DismissUpdate), (KeyCode::Char('?'), Intent::ShowHelp), @@ -1207,7 +1231,7 @@ mod tests { #[test] fn shift_is_allowed_for_shifted_characters() { - // '<' / '>' are typed with Shift; the resize keys must fire whether or not the + // '<' / '>' / '{' / '}' are typed with Shift; the resize keys must fire whether or not the // terminal reports the Shift bit — but a Ctrl chord on the same key must not. assert_eq!( map_key(KeyEvent::new(KeyCode::Char('<'), KeyModifiers::SHIFT)), @@ -1217,6 +1241,14 @@ mod tests { map_key(KeyEvent::new(KeyCode::Char('>'), KeyModifiers::NONE)), Some(Intent::GrowTree) ); + assert_eq!( + map_key(KeyEvent::new(KeyCode::Char('{'), KeyModifiers::SHIFT)), + Some(Intent::ShrinkPreview) + ); + assert_eq!( + map_key(KeyEvent::new(KeyCode::Char('}'), KeyModifiers::NONE)), + Some(Intent::GrowPreview) + ); assert_eq!( map_key(KeyEvent::new(KeyCode::Char('<'), KeyModifiers::CONTROL)), None @@ -1240,6 +1272,38 @@ mod tests { assert_eq!(map_key(k(KeyCode::Char('w'))), Some(Intent::ToggleWrap)); } + #[test] + fn pin_and_preview_resize_default_and_custom_bindings_decode() { + // `p` reaches the pin lifecycle by default, while `{` / `}` resize only the pinned + // preview share. Valid `[keys]` replacements reach the same intents and drop their + // defaults (replace semantics). + assert_eq!(map_key(k(KeyCode::Char('p'))), Some(Intent::PinPreview)); + assert_eq!(map_key(k(KeyCode::Char('{'))), Some(Intent::ShrinkPreview)); + assert_eq!(map_key(k(KeyCode::Char('}'))), Some(Intent::GrowPreview)); + + let (bindings, outcome) = resolve_with(&[ + ("pin_preview", one("P")), + ("shrink_preview", one("(")), + ("grow_preview", one(")")), + ]); + assert!( + outcome.is_empty(), + "a valid pin binding must not be rejected" + ); + assert_eq!(dec(&bindings, KeyCode::Char('P')), Some(Intent::PinPreview)); + assert_eq!(dec(&bindings, KeyCode::Char('p')), None); + assert_eq!( + dec(&bindings, KeyCode::Char('(')), + Some(Intent::ShrinkPreview) + ); + assert_eq!(dec(&bindings, KeyCode::Char('{')), None); + assert_eq!( + dec(&bindings, KeyCode::Char(')')), + Some(Intent::GrowPreview) + ); + assert_eq!(dec(&bindings, KeyCode::Char('}')), None); + } + #[test] fn f_maps_to_open_finder_and_modifier_chords_are_inert() { // `f` opens the go-to-file finder (AC-1, AC-N6). Ctrl-f / Alt-f must not fire an intent diff --git a/src/intent.rs b/src/intent.rs index e22cb3cc..18829622 100644 --- a/src/intent.rs +++ b/src/intent.rs @@ -85,12 +85,22 @@ pub enum Intent { ShrinkTree, /// Widen the tree column (move the tree/content divider right). GrowTree, + /// Shrink the frozen preview's share of the preview area by five percentage points. Inert + /// without a pin; it never moves the outer tree/content divider. + ShrinkPreview, + /// Grow the frozen preview's share of the preview area by five percentage points. Inert + /// without a pin; it never moves the outer tree/content divider. + GrowPreview, /// Force content-line wrapping on/off, overriding the per-mode default (so long lines in /// code and diffs can be wrapped on demand instead of truncated). ToggleWrap, /// Hide the tree so the content pane fills the frame / restore the two-column layout — a /// pure layout toggle for reading a file full-screen. ToggleZoom, + /// Pin or unpin the settled active file preview as an in-memory reference alongside the + /// active preview. Read-only: pinning clones an already-rendered document and never starts a + /// render, touches a file, or mutates git. + PinPreview, /// Re-read git state (working-tree status + changed-set) and re-render, so the viewer picks /// up changes made outside it — a merge, pull, or commit in another pane. Read-only. Refresh, @@ -162,7 +172,7 @@ pub enum Intent { impl Intent { /// Every intent variant — lets the dispatcher and tests enumerate the closed set so /// keyboard-completeness (AC-18) and the no-file/git-mutation invariant (AC-N3) stay checkable. - pub const ALL: [Intent; 41] = [ + pub const ALL: [Intent; 44] = [ Intent::NavUp, Intent::NavDown, Intent::PageUp, @@ -188,8 +198,11 @@ impl Intent { Intent::ToggleFocus, Intent::ShrinkTree, Intent::GrowTree, + Intent::ShrinkPreview, + Intent::GrowPreview, Intent::ToggleWrap, Intent::ToggleZoom, + Intent::PinPreview, Intent::Refresh, Intent::DismissUpdate, Intent::SwitchWorktree, @@ -244,8 +257,11 @@ mod tests { | Intent::ToggleFocus | Intent::ShrinkTree | Intent::GrowTree + | Intent::ShrinkPreview + | Intent::GrowPreview | Intent::ToggleWrap | Intent::ToggleZoom + | Intent::PinPreview | Intent::Refresh | Intent::DismissUpdate | Intent::SwitchWorktree @@ -332,11 +348,11 @@ mod tests { } #[test] - fn all_length_is_41() { + fn all_length_is_44() { assert_eq!( Intent::ALL.len(), - 41, - "Intent::ALL must have exactly 41 variants" + 44, + "Intent::ALL must have exactly 44 variants" ); } diff --git a/src/lib.rs b/src/lib.rs index c6d6e9ba..119b97c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod context; pub mod controller; pub mod editor; pub mod finder; +pub mod focus_policy; pub mod fuzzy; pub mod git; pub mod help; @@ -25,6 +26,8 @@ pub mod open_target; pub mod opener; pub mod picker; pub mod presenter; +pub mod preview; +pub mod preview_layout; pub mod proc; pub mod prompt; pub mod render; diff --git a/src/presenter.rs b/src/presenter.rs index c117694b..60dfdd8d 100644 --- a/src/presenter.rs +++ b/src/presenter.rs @@ -9,6 +9,8 @@ use crate::annotation::LineRange; use crate::git::Status; +use crate::preview::PreviewOrigin; +use crate::preview_layout::{LayoutInput, PreviewFocus, PreviewLayout, layout}; use crate::text_layout::{line_wrapped_rows_prefixed, sanitize_control}; use crate::tree::{Node, NodeKind}; use ratatui::Frame; @@ -23,9 +25,14 @@ use ratatui::widgets::{ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { Tree, + /// The frozen snapshot preview, when one is pinned. + Pinned, Content, } +/// Re-export the structural policy's tree-floor helper for existing controller callers. +pub use crate::preview_layout::min_tree_split_pct; + /// A self-expiring status hint. `dim` is set once the flash enters its fade-out phase, so the /// Presenter can render it dimmed just before it disappears. pub struct FlashLine { @@ -33,6 +40,58 @@ pub struct FlashLine { pub dim: bool, } +/// Everything one preview region needs to project already-rendered content. Active and pinned +/// previews deliberately use the same value so their notices, scroll offsets, wrapping, search, +/// and scrollbars cannot drift into separate drawing implementations. +pub struct PreviewProjection { + /// Shared handle to the applied document's styled lines — a per-frame pointer bump, never a + /// deep copy of the content. + pub content: std::sync::Arc>, + pub notices: Vec, + pub flash: Option, + pub title: Option, + pub rendering: bool, + pub scroll: u16, + pub hscroll: u16, + pub rows: u16, + pub wrap: bool, + pub pad_left: bool, + pub search: Option, + pub line_select: Option, + pub selection: Option, + /// `Some` only for a pinned snapshot. It is rendered as its own sanitized identity title. + pub origin: Option, +} + +impl PreviewProjection { + pub fn new(title: impl Into, content: Text<'static>) -> Self { + let rows = content.lines.len().min(u16::MAX as usize) as u16; + Self { + content: std::sync::Arc::new(content), + notices: Vec::new(), + flash: None, + title: Some(title.into()), + rendering: false, + scroll: 0, + hscroll: 0, + rows, + wrap: false, + pad_left: false, + search: None, + line_select: None, + selection: None, + origin: None, + } + } +} + +/// The measured drawable text interiors from one Presenter frame. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PreviewViewports { + pub active: (u16, u16), + pub pinned: Option<(u16, u16)>, +} + /// Everything the Presenter needs to draw one frame. Built by the Session Controller from /// the Tree Model (nodes + selection), Content Renderer (content + notices), and session /// focus/width. `width` is the pane width the controller observed (the narrow-split input @@ -42,25 +101,16 @@ pub struct ViewState { pub nodes: Vec, /// Index into `nodes` of the selected row. pub selected: usize, - /// The content-pane text (already sanitized/ingested by the Content Renderer). - pub content: Text<'static>, - /// Non-fatal notices to surface (truncation AC-13, renderer fallback AC-25). - pub notices: Vec, - /// A self-expiring status hint (e.g. `D`'s diff-presentation label), drawn as one line atop - /// the notices strip and styled distinctly from a warning. `None` when nothing is flashing. - pub flash: Option, + /// The tree-selected preview. It remains the sole target for active-only interaction. + pub active: PreviewProjection, + /// A frozen preview when a pin exists. `None` keeps the no-pin layout byte-for-byte unchanged. + pub pinned: Option, /// Which column has focus. pub focus: Focus, /// The pane width the controller last observed (session state — e.g. for tracking the /// narrow-split flag). The Presenter lays out from the live frame width, not this, so /// the two can never disagree; it is carried for the controller's own use. pub width: u16, - /// Vertical scroll offset of the content pane, in lines (wrapped lines when `wrap`, - /// raw lines otherwise — matching ratatui's `Paragraph::scroll` semantics). - pub content_scroll: u16, - /// Horizontal scroll offset of the content pane, in columns. Only meaningful when not - /// wrapping (ratatui ignores it under wrap); lets long code/diff lines be read sideways. - pub content_hscroll: u16, /// The tree's vertical scroll offset from the LAST drawn frame (first visible node index), /// carried back via [`PaneGeometry::tree_scroll`]. The Presenter scrolls *minimally* from it /// so selecting a row already in view (e.g. a mouse click) never jumps the viewport (#45). `0` @@ -72,19 +122,8 @@ pub struct ViewState { /// expand/collapse in the tree). The Presenter clamps it to the widest row at draw, so it can /// never over-scroll. pub tree_hscroll: u16, - /// The content's total RENDERED row count — wrapped rows under `wrap`, raw lines otherwise (the - /// controller's wrapped-aware count). The content vertical scrollbar sizes/positions against - /// this so the thumb is correct under wrap, where raw `content.lines.len()` undercounts. - pub content_rows: u16, - /// Wrap long content lines (prose: markdown / plain text) instead of truncating them. - /// Off for diffs and code, whose column alignment must be preserved. - pub wrap: bool, - /// Inset the content text one column from the left border. Set for the transformed views - /// (rendered markdown, diff) whose delegate output starts at column 0 and otherwise hugs the - /// border; syntax/plain files get the same visual gap for free from bat's line-number gutter, - /// so it stays off for them (no double gap). Applied identically by [`draw_content`] and - /// [`geometry`] so the drawn text rect and the hit-test geometry inset the border in lockstep. - pub content_pad_left: bool, + /// The pinned preview's share of the preview area (20–80). Ignored while no pin exists. + pub preview_split_pct: u16, /// The tree column's share of the width, as a percentage (the content pane takes the /// rest). Adjustable from the keyboard; used only in the wide two-column layout. pub split_pct: u16, @@ -136,30 +175,12 @@ pub struct ViewState { /// border. `None` outside a git repo or on a detached HEAD — in which case the bottom title is /// omitted entirely rather than showing a blank/placeholder branch (degrade gracefully). pub branch: Option, - /// The content pane's border title, derived from the displayed content's file path (not the - /// live tree cursor), so the title switches in lockstep with the body — it never shows a - /// freshly-selected file's name before that file's content arrives. `None` while no - /// file's content has landed yet (launch, a re-root, or a directory/empty selection); the - /// Presenter then falls back to the selected node's name (a directory) or "Content" — unless - /// [`content_rendering`](Self::content_rendering) is set, in which case it uses a neutral - /// "Content" label so the title doesn't jump to the still-loading selection. - pub content_title: Option, - /// True while an off-thread render for a file is in flight. The Presenter uses this to pick a - /// neutral title while the body shows the loading placeholder. - pub content_rendering: bool, - /// When `Some`, the content pane is drawn through [`crate::highlight::apply`] to overlay - /// match highlights on top of the rendered text (AC-9, AC-11). `None` ⇒ draw the content - /// as-is (byte-identical to today — the `None` arm is just `state.content.clone()`). - pub search: Option, - /// When `Some`, the copy-line-reference modal is active: the Presenter overlays a marker + - /// selection highlight on the content pane (AC-1, AC-7), mirroring the [`search`](Self::search) - /// overlay. `None` ⇒ draw the content as-is (byte-identical to today — the `None` arm leaves the - /// content path untouched, so no other snapshot moves). - pub line_select: Option, - /// When `Some`, an ambient character selection (a content-pane drag, no modal). Drawn as a - /// gutter-less highlight — no ▶/│ glyph, no content shift — so it reads as a plain text selection, - /// not a mode. `draw_content` gives [`line_select`](Self::line_select) precedence if both are set. - pub content_selection: Option, + /// The worktree basename to disambiguate the pinned snapshot's origin, set only when the pin + /// was captured in a DIFFERENT worktree than the one currently viewed (AC-12). `None` in the + /// common same-worktree case, where the branch alone identifies the origin and a repeated + /// basename would be noise. Decided in the controller because only it holds both full root + /// paths: two worktrees can share a basename, so the comparison cannot be made from this label. + pub pinned_foreign_root: Option, /// When `Some`, the in-app help overlay is drawn on top of everything else (AC-1, AC-5). /// `None` ⇒ no overlay. Drawn last in [`draw`] so it sits above the picker and finder. pub help: Option, @@ -201,6 +222,7 @@ pub struct PickerRowView { /// /// The Presenter uses this purely for rendering — it overlays the highlight onto `content` lines /// at draw time and never mutates any state (AC-N1, constitution read-only). +#[derive(Clone)] pub struct ContentSearch { /// The matches to highlight, in document order. pub matches: Vec, @@ -218,6 +240,7 @@ pub struct ContentSearch { /// [`crate::highlight::CURRENT_HIGHLIGHT`] on the `marker` line and a selection bar + /// [`crate::highlight::HIGHLIGHT`] across `[start, end]`; lines scrolled off-screen are simply not /// drawn (the `Paragraph` scroll offset clips them — the state stays whole). +#[derive(Clone)] pub struct LineSelectView { /// The marker (cursor) line — where `Enter` will anchor the reference. Rendered with a distinct /// caret + the stronger current-match emphasis so the user sees exactly which line is active. @@ -241,6 +264,7 @@ pub struct LineSelectView { /// A character-granular selection for the line-select overlay. `*_col` are char carets into the /// displayed line (gutter included), ordered ascending by `(line, col)`; `gutter` is the leading /// gutter width so continuation lines start their highlight at the code, not the line number. +#[derive(Clone)] pub struct CharSelView { pub start_line: usize, pub start_col: usize, @@ -620,8 +644,8 @@ fn tree_bars( /// Total rows the notice strip occupies: the persistent notices plus the optional flash line. /// Used by both [`draw_content`] and [`geometry`] so the drawn strip and the hit-test geometry /// reserve the same rows. -fn notice_strip_len(state: &ViewState) -> usize { - state.notices.len() + usize::from(state.flash.is_some()) +fn notice_strip_len(preview: &PreviewProjection) -> usize { + preview.notices.len() + usize::from(preview.flash.is_some()) } /// Split the content block interior into the notices strip (top) and the content area (below it, @@ -884,15 +908,15 @@ fn styled_run(text: &str, base: Style, selected: bool, style: Style) -> Span<'st Span::styled(text.to_string(), s) } -fn blank_annotation_style(source_line: usize, state: &ViewState) -> Style { - if let Some(line_select) = &state.line_select { +fn blank_annotation_style(source_line: usize, preview: &PreviewProjection) -> Style { + if let Some(line_select) = &preview.line_select { if source_line == line_select.marker { return ANNOTATION_STYLE.patch(crate::highlight::CURRENT_HIGHLIGHT); } if source_line >= line_select.start && source_line <= line_select.end { return ANNOTATION_STYLE.patch(crate::highlight::HIGHLIGHT); } - } else if state.content_selection.as_ref().is_some_and(|selection| { + } else if preview.selection.as_ref().is_some_and(|selection| { source_line >= selection.start_line && source_line <= selection.end_line }) { return ANNOTATION_STYLE.patch(crate::highlight::HIGHLIGHT); @@ -903,7 +927,12 @@ fn blank_annotation_style(source_line: usize, state: &ViewState) -> Style { /// Patch one bounded visible cell for each annotated source line whose rendered width is zero. /// Paragraph deliberately leaves those rows textually empty; this post-render pass supplies the /// persistent background without padding content or changing wrap/search/mouse geometry. -fn draw_blank_annotation_cells(frame: &mut Frame, text: Rect, state: &ViewState) -> Vec { +fn draw_blank_annotation_cells( + frame: &mut Frame, + text: Rect, + state: &ViewState, + preview: &PreviewProjection, +) -> Vec { if text.width == 0 || text.height == 0 { return Vec::new(); } @@ -915,15 +944,15 @@ fn draw_blank_annotation_cells(frame: &mut Frame, text: Rect, state: &ViewState) return Vec::new(); } - let scroll = state.content_scroll as usize; + let scroll = preview.scroll as usize; let visible_end = scroll.saturating_add(text.height as usize); // Active line-select adds a ▶/│ gutter column; passive range flash does not. - let prefix = usize::from(state.line_select.as_ref().is_some_and(|ls| !ls.passive)); + let prefix = usize::from(preview.line_select.as_ref().is_some_and(|ls| !ls.passive)); let mut display_row = 0usize; let mut range_index = 0usize; let mut painted = Vec::with_capacity(text.height as usize); - for (index, line) in state.content.lines.iter().enumerate() { + for (index, line) in preview.content.lines.iter().enumerate() { let source_line = index + 1; let annotated = range_covers( &state.annotation_indicators.displayed_line_ranges, @@ -931,7 +960,7 @@ fn draw_blank_annotation_cells(frame: &mut Frame, text: Rect, state: &ViewState) source_line, ); let line_start = display_row; - let rows = if state.wrap { + let rows = if preview.wrap { line_wrapped_rows_prefixed(line, text.width as usize, prefix) } else { 1 @@ -951,7 +980,7 @@ fn draw_blank_annotation_cells(frame: &mut Frame, text: Rect, state: &ViewState) } let position = Position::new(text.x, text.y + y_offset as u16); if let Some(cell) = frame.buffer_mut().cell_mut(position) { - cell.set_style(blank_annotation_style(source_line, state)); + cell.set_style(blank_annotation_style(source_line, preview)); painted.push(position); } } @@ -1051,9 +1080,9 @@ fn draw_tree(frame: &mut Frame, area: Rect, state: &ViewState) { /// Shared by [`draw_content`] and [`geometry`] so the drawn text rect and the hit-test geometry /// inset the border identically — a mismatch would map a content-pane click one column off under /// the [`ViewState::content_pad_left`] gap. -fn content_block(state: &ViewState) -> Block<'static> { +fn content_block(preview: &PreviewProjection) -> Block<'static> { let block = Block::bordered(); - if state.content_pad_left { + if preview.pad_left { block.padding(Padding::left(1)) } else { block @@ -1062,7 +1091,14 @@ fn content_block(state: &ViewState) -> Block<'static> { /// Draw the right column: a notices strip (if any) above the content pane. Returns the /// content viewport `(width, height)` so the controller can clamp scrolling to it. -fn draw_content(frame: &mut Frame, area: Rect, state: &ViewState) -> (u16, u16) { +fn draw_content( + frame: &mut Frame, + area: Rect, + state: &ViewState, + preview: &PreviewProjection, + active: bool, + pinned_space_notice: Option<&str>, +) -> (u16, u16) { // the title is derived from the DISPLAYED content's file (`content_title`), not the // live tree cursor, so it switches in lockstep with the body — the pane never shows a newly- // selected file's name over the previous file's body while the new render is in flight. @@ -1070,10 +1106,12 @@ fn draw_content(frame: &mut Frame, area: Rect, state: &ViewState) -> (u16, u16) // directory/empty selection); in that case fall back to the selected node's name (a directory) // or "Content" — but only when NO render is in flight, otherwise the fallback would pick up // the still-loading selection's name and re-introduce the title-ahead-of-body bug. - let applied_title = state.content_title.is_some(); - let mut title = if let Some(name) = &state.content_title { + let applied_title = preview.title.is_some(); + let mut title = if let Some(origin) = &preview.origin { + pinned_origin_title(origin, state.pinned_foreign_root.as_deref()) + } else if let Some(name) = &preview.title { sanitize_control(name) - } else if !state.content_rendering { + } else if active && !preview.rendering { state .nodes .get(state.selected) @@ -1082,7 +1120,7 @@ fn draw_content(frame: &mut Frame, area: Rect, state: &ViewState) -> (u16, u16) } else { "Content".to_string() }; - if applied_title && state.annotation_indicators.displayed_file_annotated { + if active && applied_title && state.annotation_indicators.displayed_file_annotated { title.insert(0, '@'); } // Persistent bottom-border chips: annotation count on the left (only when nonzero and it @@ -1090,20 +1128,25 @@ fn draw_content(frame: &mut Frame, area: Rect, state: &ViewState) -> (u16, u16) // annotation chip deliberately names no key because ShowAnnotations is configurable. let hint_text = sanitize_control(HELP_HINT); let hint = Line::styled(hint_text.clone(), Style::new().fg(Color::Reset)).right_aligned(); - let annotation_chip = (state.annotation_count > 0) + let annotation_chip = (active && state.annotation_count > 0) .then(|| sanitize_control(&format!("annotations: {}", state.annotation_count))); let chip_fits = annotation_chip.as_ref().is_some_and(|chip| { Line::from(chip.as_str()).width() + 1 + Line::from(hint_text.as_str()).width() <= area.width.saturating_sub(2) as usize }); - let mut block = content_block(state).title(title).title_bottom(hint); + let mut block = content_block(preview).title(title); + if active { + block = block.title_bottom(hint); + } if chip_fits { block = block.title_bottom(Line::styled( annotation_chip.expect("checked as present"), Style::new().fg(Color::Reset), )); } - let block = block.border_style(border_style(state.focus == Focus::Content)); + let focused = + (active && state.focus == Focus::Content) || (!active && state.focus == Focus::Pinned); + let block = block.border_style(border_style(focused)); let inner = block.inner(area); frame.render_widget(block, area); @@ -1111,10 +1154,13 @@ fn draw_content(frame: &mut Frame, area: Rect, state: &ViewState) -> (u16, u16) // it can never crowd out the file itself; the file + its scrollbars fill the area below it. // The self-expiring flash (if any) leads the strip, styled distinctly from a yellow warning // (cyan, dimming to gray as it fades) so a status hint never reads as an error. - let (notices_rect, content_area) = content_notice_split(inner, notice_strip_len(state)); + let (notices_rect, content_area) = content_notice_split( + inner, + notice_strip_len(preview) + usize::from(pinned_space_notice.is_some()), + ); if notices_rect.height > 0 { let mut notice_lines: Vec = Vec::new(); - if let Some(flash) = &state.flash { + if let Some(flash) = &preview.flash { let color = if flash.dim { Color::DarkGray } else { @@ -1126,11 +1172,17 @@ fn draw_content(frame: &mut Frame, area: Rect, state: &ViewState) -> (u16, u16) )); } notice_lines.extend( - state + preview .notices .iter() .map(|n| Line::styled(sanitize_control(n), Style::new().fg(Color::Yellow))), ); + if let Some(notice) = pinned_space_notice { + notice_lines.push(Line::styled( + sanitize_control(notice), + Style::new().fg(Color::Yellow), + )); + } frame.render_widget(Paragraph::new(notice_lines), notices_rect); } @@ -1139,9 +1191,9 @@ fn draw_content(frame: &mut Frame, area: Rect, state: &ViewState) -> (u16, u16) // (`content_rows`) — wrapped rows under wrap, raw lines otherwise — so the bar is correct even // for a few long lines that wrap past the viewport. The horizontal bar uses the widest raw line // and is suppressed under wrap, where there is nothing to scroll sideways. - let total_rows = state.content_rows as usize; - let max_width = content_max_line_width(&state.content); - let (text, vbar, hbar) = content_bars(content_area, total_rows, max_width, state.wrap); + let total_rows = preview.rows as usize; + let max_width = content_max_line_width(&preview.content); + let (text, vbar, hbar) = content_bars(content_area, total_rows, max_width, preview.wrap); // Persistent annotation styling is the base. The existing mutually-exclusive active overlay // then patches over it: line-select first, ambient selection second, committed search third. @@ -1152,36 +1204,42 @@ fn draw_content(frame: &mut Frame, area: Rect, state: &ViewState) -> (u16, u16) // it and an active overlay (line-select / selection / search) deep-clones the content a SECOND // time per keystroke, on a file up to `preview_max_lines` long, for users with no annotations at // all. Same waste guarded in `draw_blank_annotation_cells` below. - let ranges = &state.annotation_indicators.displayed_line_ranges; + let ranges: &[LineRange] = if active { + &state.annotation_indicators.displayed_line_ranges + } else { + &[] + }; let annotated = - (!ranges.is_empty()).then(|| apply_annotation_lines(&state.content.lines, ranges)); - let base: &[Line<'static>] = annotated.as_deref().unwrap_or(&state.content.lines); - let content_text = if let Some(ls) = &state.line_select { + (!ranges.is_empty()).then(|| apply_annotation_lines(&preview.content.lines, ranges)); + let base: &[Line<'static>] = annotated.as_deref().unwrap_or(&preview.content.lines); + let content_text = if active && let Some(ls) = &preview.line_select { ratatui::text::Text::from(apply_line_select(base, ls)) - } else if let Some(cs) = &state.content_selection { + } else if active && let Some(cs) = &preview.selection { ratatui::text::Text::from(apply_char_selection(base, cs)) - } else if let Some(cs) = &state.search { + } else if let Some(cs) = &preview.search { ratatui::text::Text::from(crate::highlight::apply(base, &cs.matches, cs.current)) } else if let Some(annotated) = annotated { ratatui::text::Text::from(annotated) } else { - // No ranges and no overlay: exactly main's pre-annotation path. - state.content.clone() + // No ranges and no overlay: exactly main's pre-annotation path. `Paragraph` needs an + // owned `Text`, so this one draw-side copy remains; the projection handle stays shared. + (*preview.content).clone() }; - let mut content = - Paragraph::new(content_text).scroll((state.content_scroll, state.content_hscroll)); - if state.wrap { + let mut content = Paragraph::new(content_text).scroll((preview.scroll, preview.hscroll)); + if preview.wrap { content = content.wrap(Wrap { trim: false }); } frame.render_widget(content, text); - draw_blank_annotation_cells(frame, text, state); + if active { + draw_blank_annotation_cells(frame, text, state, preview); + } if let Some(track) = vbar { draw_vscrollbar( frame, track, total_rows, - state.content_scroll as usize, + preview.scroll as usize, text.height as usize, ); } @@ -1190,23 +1248,31 @@ fn draw_content(frame: &mut Frame, area: Rect, state: &ViewState) -> (u16, u16) frame, track, max_width, - state.content_hscroll as usize, + preview.hscroll as usize, text.width as usize, ); } (text.width, text.height) } -/// Split the frame into the body (the two columns) and an optional one-row remote-notice status. -/// The status is present exactly when supplied (and the frame is tall enough to spare a row). -/// Shared by [`draw`] and [`geometry`] so the drawn layout and the hit-test geometry carve the -/// same body rect, a mouse click is never mapped against stale geometry. -fn body_and_remote_notice_status(area: Rect, state: &ViewState) -> (Rect, Option) { - if state.remote_notice_status.is_none() || area.height < 2 { - return (area, None); +/// The frozen pin's origin identity, kept on its own title so a worktree switch cannot make the +/// reference surface look like it belongs to the active tree. The captured path is deliberately +/// omitted: it led the title and clipped the origin away on a narrow pane, defeating the very +/// distinction AC-12 exists to draw. It remains available through the widen notice and the pinned +/// copy actions. +/// +/// The worktree is named only when `foreign_root` says the pin came from another one. A pin from +/// the viewed worktree reads `[branch]`, because repeating the current worktree's own name each +/// time buys no distinction and costs the width that clipped the title in the first place. +fn pinned_origin_title(origin: &PreviewOrigin, foreign_root: Option<&str>) -> String { + let branch = match origin.branch() { + crate::preview::BranchState::Named(branch) => sanitize_control(branch), + crate::preview::BranchState::Detached => "detached".to_string(), + }; + match foreign_root { + Some(root) => format!("Pinned: [{} @ {}]", branch, sanitize_control(root)), + None => format!("Pinned: [{branch}]"), } - let parts = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area); - (parts[0], Some(parts[1])) } /// Draw the supplied one-row remote-notice status. Reversed (theme-relative) so it reads as a @@ -1222,24 +1288,6 @@ fn draw_remote_notice_status(frame: &mut Frame, area: Rect, status: &str) { frame.render_widget(Paragraph::new(line), area); } -/// Carve the optional one-row bottom prompt off the very bottom, then the optional remote-notice -/// status off what remains (so the prompt sits below the status). Reuses -/// [`body_and_remote_notice_status`] so a frame with no prompt lays out exactly as before. Shared -/// by [`draw`] and [`geometry`] so the body rect they use can never disagree. -fn body_remote_notice_status_prompt( - area: Rect, - state: &ViewState, -) -> (Rect, Option, Option) { - let (above_prompt, prompt) = if state.prompt.is_some() && area.height >= 2 { - let parts = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area); - (parts[0], Some(parts[1])) - } else { - (area, None) - }; - let (body, remote_notice_status) = body_and_remote_notice_status(above_prompt, state); - (body, remote_notice_status, prompt) -} - /// Draw the one-row bottom prompt (`Go to line: 42` / later search). Reversed (theme-relative) /// so it reads as a prompt bar on any palette — previously `Black`-on-`Gray`, which /// ignored the terminal theme. Sanitized (AC-27), clipped to its row. @@ -1251,75 +1299,39 @@ fn draw_prompt_line(frame: &mut Frame, area: Rect, prompt: &str) { frame.render_widget(Paragraph::new(line), area); } -/// Below this pane width the viewer drops to a single, focused column (AC-21). -const NARROW_SPLIT: u16 = 80; - -/// The smallest tree column the split may render, as a percentage of `pane_width` — the percentage -/// that yields at least [`crate::config::MIN_TREE_MAX_COLS`] columns. Pane-aware on purpose: a fixed -/// percentage floor is wrong in absolute terms on a very wide pane (10% of a 1000-column pane is 100 -/// columns), so a manually-narrowed or `tree_max_cols`-capped tree could not be represented and would -/// jump up to that floor. Expressing the floor as "≥ N columns" lets the tree stay narrow on any pane -/// width while never collapsing on a small one. Shared by [`columns`] (render) and the controller's -/// interactive resize so the two agree on how narrow the tree can get. -pub fn min_tree_split_pct(pane_width: u16) -> u16 { - if pane_width == 0 { - return crate::config::MIN_TREE_MAX_COLS; - } - let pct = (crate::config::MIN_TREE_MAX_COLS as u32 * 100).div_ceil(pane_width as u32) as u16; - // Never 0 (a tree needs some width); capped well below the 90% upper bound so a small pane can't - // force a floor that would crowd out the content pane. - pct.clamp(1, 40) -} - -/// The column split for the current frame: `(tree_area, content_area, divider_x)`. A column -/// is `None` when not drawn (narrow layout shows only the focused one). Shared by [`draw`] and -/// [`geometry`] so the drawn layout and the hit-test geometry can never disagree. -fn columns(area: Rect, state: &ViewState) -> (Option, Option, Option) { - // Zoom hides the tree entirely: the content pane fills the frame regardless of width or - // focus, so there is no tree interior and no divider to hit-test. - if state.zoomed { - return (None, Some(area), None); - } - if area.width < NARROW_SPLIT { - return match state.focus { - Focus::Tree => (Some(area), None, None), - Focus::Content => (None, Some(area), None), - }; - } - let tree_pct = state.split_pct.clamp(min_tree_split_pct(area.width), 90); - // The tree is `min(tree_pct% of the pane, tree_max_cols)`: the percentage governs normal panes, - // and the column cap reins the tree in on a very wide pane (a full tab, a big monitor) so it - // doesn't over-allocate blank space past the filenames. The cap is a *default* ceiling only: - // once the user has resized the split by hand (`split_manual` — a divider drag or the grow/shrink - // keys), it is lifted so the tree honours exactly what they dragged to, otherwise the resize - // would look frozen. Only when the cap actually bites do we switch to a fixed `Length(cap)` - // column; otherwise keep the exact `Percentage` layout (so nothing shifts when it doesn't). The - // tree keeps this width whichever side it sits on; only the column ORDER flips. `cols[1]` is - // always the right-hand column, so the divider — the boundary where the two bordered blocks abut - // — is `cols[1].x` in either layout (the hit-test and drag geometry both derive from this, so - // neither needs to know the side). - let pct_cols = (area.width as u32 * tree_pct as u32 / 100) as u16; - let cap_bites = !state.split_manual && pct_cols > state.tree_max_cols; - let (tree_c, content_c) = if cap_bites { - // Cap bites: fix the tree at the cap and give the content pane the rest. - (Constraint::Length(state.tree_max_cols), Constraint::Min(0)) - } else { - ( - Constraint::Percentage(tree_pct), - Constraint::Percentage(100 - tree_pct), - ) - }; - match state.tree_position { - crate::config::TreePosition::Left => { - let cols = Layout::horizontal([tree_c, content_c]).split(area); - (Some(cols[0]), Some(cols[1]), Some(cols[1].x)) - } - crate::config::TreePosition::Right => { - let cols = Layout::horizontal([content_c, tree_c]).split(area); - // content = cols[0] (left), tree = cols[1] (right); divider at their boundary. - (Some(cols[1]), Some(cols[0]), Some(cols[1].x)) - } - } +/// Translate the presenter's legacy no-pin view state into the structural policy. Later tasks pass +/// the policy's pinned state directly; keeping the conversion here preserves every current drawing +/// and hit-test caller while ensuring they already share one frame-carving path. +fn pinned_space_notice(layout: &PreviewLayout, state: &ViewState) -> Option { + layout.pinned_hidden_for_space.then(|| { + let path = state + .pinned + .as_ref() + .and_then(|preview| preview.origin.as_ref()) + .map(|origin| origin.root_relative_path().to_string_lossy().into_owned()) + .unwrap_or_else(|| "preview".to_string()); + format!("Pinned: {path} — widen to view") + }) +} + +fn structural_layout(area: Rect, state: &ViewState) -> PreviewLayout { + layout(LayoutInput { + area, + has_prompt: state.prompt.is_some(), + has_remote_status: state.remote_notice_status.is_some(), + has_pin: state.pinned.is_some(), + focus: match state.focus { + Focus::Tree => PreviewFocus::Tree, + Focus::Pinned => PreviewFocus::Pinned, + Focus::Content => PreviewFocus::Active, + }, + tree_hidden: state.zoomed, + preview_split_pct: state.preview_split_pct, + tree_split_pct: state.split_pct, + tree_position: state.tree_position, + tree_max_cols: state.tree_max_cols, + tree_split_manual: state.split_manual, + }) } /// Hit-test geometry for mouse input, derived from the same split [`draw`] renders. @@ -1353,7 +1365,23 @@ pub struct PaneGeometry { /// The content pane's in-pane scrollbar tracks (1-cell rects), present only when drawn. pub content_vbar: Option, pub content_hbar: Option, + /// The frozen pinned preview's text interior. Kept distinct from `content_inner`, which is + /// the active preview's established compatibility field. + pub pinned_inner: Option, + /// Hit rect for the pinned preview's top-border identity title. + pub pinned_title_rect: Option, + /// The pinned preview's in-pane scrollbar tracks, present only when that preview is drawn + /// and its displayed text overflows. + pub pinned_vbar: Option, + pub pinned_hbar: Option, pub divider_x: Option, + /// The entire adjacent pinned/active preview area. These measured bounds let the controller + /// map a preview-divider drag to a ratio without moving the tree/content divider. + pub preview_area_x: u16, + pub preview_area_width: u16, + /// The divider between the pinned (left) and active (right) preview rectangles. This is + /// distinct from `divider_x`, which remains the existing tree/content divider. + pub preview_divider_x: Option, /// The screen rect where finder result rows are drawn, `None` when the finder is closed or /// has no rows (empty query or zero matches). Used by the controller to map a mouse click to /// a result row index: `row - finder_rows.y + finder_scroll` gives the match list index. @@ -1406,8 +1434,13 @@ pub struct PaneGeometry { /// renders, so a click is never mapped against stale geometry. The interior of a bordered /// block is its area inset by one cell on each side (the title does not change it). pub fn geometry(area: Rect, state: &ViewState) -> PaneGeometry { - let (body, _remote_notice_status, _prompt) = body_remote_notice_status_prompt(area, state); - let (tree, content, divider_x) = columns(body, state); + let layout = structural_layout(area, state); + let active = &state.active; + let body = layout.body; + let tree = layout.tree; + let content = layout.active; + let pinned_space_notice = pinned_space_notice(&layout, state); + let divider_x = layout.tree_divider.map(|divider| divider.x); let inner = |r: Rect| Block::bordered().inner(r); // Tree: the SAME layout `draw_tree` computes (text rect + in-pane bar tracks), so a click maps @@ -1447,20 +1480,57 @@ pub fn geometry(area: Rect, state: &ViewState) -> PaneGeometry { height: 1, }); let (content_inner, content_vbar, content_hbar) = - match content.map(|r| content_block(state).inner(r)) { + match content.map(|r| content_block(active).inner(r)) { Some(ci) => { - let (_notices, content_area) = content_notice_split(ci, notice_strip_len(state)); + let (_notices, content_area) = content_notice_split( + ci, + notice_strip_len(active) + usize::from(pinned_space_notice.is_some()), + ); let (text, v, h) = content_bars( content_area, - state.content_rows as usize, - content_max_line_width(&state.content), - state.wrap, + active.rows as usize, + content_max_line_width(&active.content), + active.wrap, ); (Some(text), v, h) } None => (None, None, None), }; + // A pinned preview uses the exact same block, notices, and scrollbar policy as the active + // preview, but its geometry must stay separate so mouse input cannot mutate active state. + let pinned_title_rect = layout.pinned.map(|rect| Rect { + x: rect.x.saturating_add(1), + y: rect.y, + width: rect.width.saturating_sub(2), + height: 1, + }); + let (pinned_inner, pinned_vbar, pinned_hbar) = match (layout.pinned, state.pinned.as_ref()) { + (Some(rect), Some(pinned)) => { + let inner = content_block(pinned).inner(rect); + let (_notices, content_area) = content_notice_split(inner, notice_strip_len(pinned)); + let (text, v, h) = content_bars( + content_area, + pinned.rows as usize, + content_max_line_width(&pinned.content), + pinned.wrap, + ); + (Some(text), v, h) + } + _ => (None, None, None), + }; + let (preview_area_x, preview_area_width) = match (layout.pinned, layout.active) { + (Some(pinned), Some(active)) => { + let left = active.x.min(pinned.x); + let right = active + .x + .saturating_add(active.width) + .max(pinned.x.saturating_add(pinned.width)); + (left, right.saturating_sub(left)) + } + _ => (0, 0), + }; + // Finder: if the finder overlay is open, compute its layout with the same helper // `draw_finder_overlay` uses (same `area` = `frame.area()` = the full terminal rect), // so the hit-test geometry agrees with what is drawn. @@ -1509,7 +1579,14 @@ pub fn geometry(area: Rect, state: &ViewState) -> PaneGeometry { content_title_rect, content_vbar, content_hbar, + pinned_inner, + pinned_title_rect, + pinned_vbar, + pinned_hbar, divider_x, + preview_area_x, + preview_area_width, + preview_divider_x: layout.preview_divider.map(|divider| divider.x), finder_rows, finder_scroll, finder_max_hscroll, @@ -1523,17 +1600,20 @@ pub fn geometry(area: Rect, state: &ViewState) -> PaneGeometry { } } -/// Draw the viewer for the given state, returning the content viewport `(width, height)` -/// the content pane was drawn into — `(0, 0)` when the content pane is not visible (narrow -/// layout with the tree focused). The controller uses it to clamp content scrolling. +/// Draw the viewer for the given state, returning named active/pinned content viewports. The +/// active viewport is `(0, 0)` when the active region is hidden; the optional pinned viewport is +/// present only when that frozen region was drawn. The controller uses only the active feedback +/// for live reflow, while the aggregate keeps the two regions unambiguous. /// /// At ≥ 80 columns both columns are shown side by side. Narrower than that, only the focused /// column is drawn — full width — so the active content stays readable (AC-21). The split is /// taken from the **live frame width** (via [`columns`]), so it can never disagree with the /// geometry it is drawn into (a stale `state.width` cannot desync the layout). -pub fn draw(frame: &mut Frame, state: &ViewState) -> (u16, u16) { - let (body, remote_notice_area, prompt_area) = - body_remote_notice_status_prompt(frame.area(), state); +pub fn draw(frame: &mut Frame, state: &ViewState) -> PreviewViewports { + let layout = structural_layout(frame.area(), state); + let active = &state.active; + let remote_notice_area = layout.remote_status; + let prompt_area = layout.prompt; if let (Some(area), Some(status)) = (remote_notice_area, state.remote_notice_status.as_deref()) { draw_remote_notice_status(frame, area, status); @@ -1541,12 +1621,23 @@ pub fn draw(frame: &mut Frame, state: &ViewState) -> (u16, u16) { if let (Some(area), Some(prompt)) = (prompt_area, state.prompt.as_deref()) { draw_prompt_line(frame, area, prompt); } - let (tree, content, _divider) = columns(body, state); - if let Some(area) = tree { + if let Some(area) = layout.tree { draw_tree(frame, area, state); } - let dims = match content { - Some(area) => draw_content(frame, area, state), + let pinned_space_notice = pinned_space_notice(&layout, state); + let pinned = match (layout.pinned, state.pinned.as_ref()) { + (Some(area), Some(preview)) => Some(draw_content(frame, area, state, preview, false, None)), + _ => None, + }; + let active = match layout.active { + Some(area) => draw_content( + frame, + area, + state, + active, + true, + pinned_space_notice.as_deref(), + ), None => (0, 0), }; // The worktree picker is a modal overlay: drawn last, on TOP of whatever columns are @@ -1574,7 +1665,7 @@ pub fn draw(frame: &mut Frame, state: &ViewState) -> (u16, u16) { if let Some(help) = &state.help { draw_help_overlay(frame, frame.area(), help); } - dims + PreviewViewports { active, pinned } } /// A `Rect` of outer size `w` × `h`, centered in `area` and clamped so it never exceeds `area`. diff --git a/src/preview.rs b/src/preview.rs new file mode 100644 index 00000000..6fccca65 --- /dev/null +++ b/src/preview.rs @@ -0,0 +1,532 @@ +//! Preview values shared by the active and pinned preview surfaces. + +use crate::infile::SearchState; +use crate::view_policy::ViewMode; +use ratatui::text::Text; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// The branch state captured with a preview origin. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum BranchState { + /// The branch checked out when the preview was rendered. + Named(String), + /// The preview came from a detached HEAD (or another branchless root). + Detached, +} + +/// The identity used to determine whether two previews represent the same file. +/// +/// A root identity captures both its root/worktree and branch state. A preview is the same file +/// only when that root identity and its root-relative path match. The absolute path is retained +/// for copying, but does not participate in pin replacement/removal decisions. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PreviewIdentity { + pub root: PathBuf, + pub branch: BranchState, + pub root_relative_path: PathBuf, +} + +/// The captured location and branch metadata for a display-ready preview. +/// +/// This value is purely descriptive. It does not keep the root alive or access the filesystem +/// after capture, allowing a pinned preview to survive a worktree switch or removal. +/// +/// ```compile_fail +/// use herdr_file_viewer::preview::{ +/// BranchState, PreviewDocument, PreviewOrigin, PreviewPresentation, +/// }; +/// +/// fn mutate( +/// document: &mut PreviewDocument, +/// origin: &mut PreviewOrigin, +/// presentation: &mut PreviewPresentation, +/// ) { +/// document.notices.push("changed".into()); +/// origin.branch = BranchState::Detached; +/// presentation.wrap = true; +/// } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreviewOrigin { + /// The root/worktree that produced the preview. + root: PathBuf, + /// The branch state captured from that root. + branch: BranchState, + /// The fully-qualified path captured for an absolute-path copy request. + absolute_path: PathBuf, + /// The path below the root, captured for display and relative-path copying. + root_relative_path: PathBuf, +} + +impl PreviewOrigin { + /// Capture the location and branch metadata for a preview. + pub fn new( + root: PathBuf, + branch: BranchState, + absolute_path: PathBuf, + root_relative_path: PathBuf, + ) -> Self { + Self { + root, + branch, + absolute_path, + root_relative_path, + } + } + + /// Return the root/worktree that produced the preview. + pub fn root(&self) -> &Path { + &self.root + } + + /// Return the branch state captured from that root. + pub fn branch(&self) -> &BranchState { + &self.branch + } + + /// Return the fully-qualified path retained for absolute-path copy requests. + pub fn absolute_path(&self) -> &Path { + &self.absolute_path + } + + /// Return the root-relative path retained for display and relative-path copy requests. + pub fn root_relative_path(&self) -> &Path { + &self.root_relative_path + } + + /// Return the stable file identity used by the pin lifecycle. + pub fn identity(&self) -> PreviewIdentity { + PreviewIdentity { + root: self.root.clone(), + branch: self.branch.clone(), + root_relative_path: self.root_relative_path.clone(), + } + } +} + +/// Presentation facts that determine how already-rendered preview content is projected. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PreviewPresentation { + /// The view that produced the captured display content. + view_mode: ViewMode, + /// Whether the captured preview is displayed with line wrapping. + wrap: bool, + /// Whether transformed content needs a left inset inside its border. + pad_left: bool, +} + +impl PreviewPresentation { + /// Capture the projection facts applied to preview content. + pub fn new(view_mode: ViewMode, wrap: bool, pad_left: bool) -> Self { + Self { + view_mode, + wrap, + pad_left, + } + } + + /// Return the view that produced the captured display content. + pub fn view_mode(&self) -> ViewMode { + self.view_mode + } + + /// Return whether the captured preview is displayed with line wrapping. + pub fn wrap(&self) -> bool { + self.wrap + } + + /// Return whether transformed content needs a left inset inside its border. + pub fn pad_left(&self) -> bool { + self.pad_left + } +} + +/// An immutable, display-ready file preview. +/// +/// This value contains only applied file-preview data. Loading placeholders, selection state, +/// action notices, and render lifecycle flags remain session-controller concerns so a cloned +/// document is safe to freeze as a pin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreviewDocument { + /// Bounded, sanitized display content, including its renderer-provided styling. Shared, not + /// owned: projections and pins clone the handle, never the styled lines. + content: Arc>, + /// Notices specific to this rendered content, such as truncation or a renderer fallback. + notices: Vec, + /// Source lines retained for source-mapped views; absent for transformed views. + source: Option>, + /// The display mode and projection details captured with the content. + presentation: PreviewPresentation, + /// The original file location and branch metadata. + origin: PreviewOrigin, +} + +impl PreviewDocument { + /// Capture a complete, display-ready preview. + pub fn new( + content: Text<'static>, + notices: Vec, + source: Option>, + presentation: PreviewPresentation, + origin: PreviewOrigin, + ) -> Self { + Self { + content: Arc::new(content), + notices, + source, + presentation, + origin, + } + } + + /// Return the bounded, sanitized display content. + pub fn content(&self) -> &Text<'static> { + &self.content + } + + /// Return a cheap shared handle to the display content, for per-frame projections. + pub fn shared_content(&self) -> Arc> { + Arc::clone(&self.content) + } + + /// A copy of this document under a different projection presentation, sharing the same + /// content. The document stays immutable; a presentation change never re-copies the lines. + pub fn with_presentation(&self, presentation: PreviewPresentation) -> Self { + Self { + content: Arc::clone(&self.content), + notices: self.notices.clone(), + source: self.source.clone(), + presentation, + origin: self.origin.clone(), + } + } + + /// Return notices specific to this rendered content. + pub fn notices(&self) -> &[String] { + &self.notices + } + + /// Return source lines for source-mapped views, when retained. + pub fn source(&self) -> Option<&[String]> { + self.source.as_deref() + } + + /// Return the display mode and projection details captured with the content. + pub fn presentation(&self) -> &PreviewPresentation { + &self.presentation + } + + /// Return the original file location and branch metadata. + pub fn origin(&self) -> &PreviewOrigin { + &self.origin + } +} + +/// A displayed-text selection local to one preview. +/// +/// The active preview uses this value for its ambient mouse-drag selection and for the line-select +/// modal. A pinned preview never accepts selection input, but keeping the value with the other +/// interaction data makes a copied interaction state self-contained. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PreviewSelection { + anchor: usize, + marker: usize, + anchor_col: usize, + marker_col: usize, + char_mode: bool, +} + +impl PreviewSelection { + /// Start a collapsed whole-line selection on `line`. + pub fn new(line: usize) -> Self { + Self { + anchor: line, + marker: line, + anchor_col: 0, + marker_col: 0, + char_mode: false, + } + } + + /// Begin a character-granular selection, clamped to the displayed line extent. + pub fn begin_char(&mut self, line: usize, col: usize, last: usize) { + let line = Self::clamp(line, last); + self.anchor = line; + self.marker = line; + self.anchor_col = col; + self.marker_col = col; + self.char_mode = true; + } + + /// Extend a character-granular selection, retaining its original anchor. + pub fn drag_char(&mut self, line: usize, col: usize, last: usize) { + self.marker = Self::clamp(line, last); + self.marker_col = col; + self.char_mode = true; + } + + /// Return whether this is a character-granular selection. + pub fn is_char_mode(&self) -> bool { + self.char_mode + } + + /// Return the ordered character span as `(line, column)` carets. + pub fn char_span(&self) -> ((usize, usize), (usize, usize)) { + let anchor = (self.anchor, self.anchor_col); + let marker = (self.marker, self.marker_col); + if anchor <= marker { + (anchor, marker) + } else { + (marker, anchor) + } + } + + /// Move and collapse the marker onto a whole-line selection. + pub fn move_to(&mut self, line: usize, last: usize) { + self.marker = Self::clamp(line, last); + self.anchor = self.marker; + self.char_mode = false; + } + + /// Move the marker while retaining the whole-line selection anchor. + pub fn extend_to(&mut self, line: usize, last: usize) { + self.marker = Self::clamp(line, last); + self.char_mode = false; + } + + /// Return the marker line, using the preview's one-based line convention. + pub fn marker(&self) -> usize { + self.marker + } + + /// Return the selected whole-line range in ascending order. + pub fn selection(&self) -> (usize, usize) { + if self.anchor <= self.marker { + (self.anchor, self.marker) + } else { + (self.marker, self.anchor) + } + } + + fn clamp(line: usize, last: usize) -> usize { + line.max(1).min(last.max(1)) + } +} + +/// Mutable, session-only interactions for one preview document. +/// +/// Active and pinned previews receive separate instances so their viewport and search state never +/// share mutations. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PreviewInteractionState { + /// Vertical viewport offset in displayed rows. + pub vertical_scroll: u16, + /// Horizontal viewport offset in display columns. + pub horizontal_scroll: u16, + /// The last measured drawable width for this preview. + pub viewport_width: u16, + /// The last measured drawable height for this preview. + pub viewport_height: u16, + /// The preview-local in-file search state, when a search has begun. + pub search: Option, + /// A transient displayed-text selection. Only the active preview projects or mutates it. + pub selection: Option, +} + +#[cfg(test)] +mod tests { + use super::{ + BranchState, PreviewDocument, PreviewInteractionState, PreviewOrigin, PreviewPresentation, + }; + use crate::infile::SearchState; + use crate::search::Match; + use crate::view_policy::ViewMode; + use ratatui::style::{Color, Modifier, Style}; + use ratatui::text::{Line, Span, Text}; + use std::path::PathBuf; + use std::sync::Arc; + + fn origin(root: &str) -> PreviewOrigin { + PreviewOrigin { + root: PathBuf::from(root), + branch: BranchState::Named("feature/pin".into()), + absolute_path: PathBuf::from(root).join("src/lib.rs"), + root_relative_path: PathBuf::from("src/lib.rs"), + } + } + + fn document() -> PreviewDocument { + PreviewDocument { + content: Arc::new(Text::from(Line::from(Span::styled( + "pub fn run() {}", + Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD), + )))), + notices: vec!["renderer fallback: plain text".into()], + source: Some(vec!["pub fn run() {}".into()]), + presentation: PreviewPresentation { + view_mode: ViewMode::SyntaxContent, + wrap: false, + pad_left: false, + }, + origin: origin("/worktrees/feature"), + } + } + + #[test] + fn preview_document_equality_covers_every_captured_value() { + let document = document(); + assert_eq!(document.clone(), document); + + assert_ne!( + document, + PreviewDocument { + content: Arc::new(Text::from(Line::from(Span::styled( + "pub fn run() {}", + Style::new().fg(Color::Green).add_modifier(Modifier::BOLD), + )))), + ..document.clone() + }, + "styled display content is part of a frozen document" + ); + assert_ne!( + document, + PreviewDocument { + notices: vec!["truncated preview".into()], + ..document.clone() + }, + "content-specific notices are part of a frozen document" + ); + assert_ne!( + document, + PreviewDocument { + source: None, + ..document.clone() + }, + "retained source metadata is part of a frozen document" + ); + assert_ne!( + document, + PreviewDocument { + presentation: PreviewPresentation { + view_mode: ViewMode::RenderedMarkdown, + wrap: true, + pad_left: true, + }, + ..document.clone() + }, + "presentation metadata is part of a frozen document" + ); + assert_ne!( + document, + PreviewDocument { + origin: PreviewOrigin { + absolute_path: PathBuf::from("/worktrees/feature/src/main.rs"), + ..origin("/worktrees/feature") + }, + ..document.clone() + }, + "the absolute path is part of a frozen document" + ); + assert_ne!( + document, + PreviewDocument { + origin: PreviewOrigin { + root_relative_path: PathBuf::from("src/main.rs"), + ..origin("/worktrees/feature") + }, + ..document.clone() + }, + "the root-relative path is part of a frozen document" + ); + assert_ne!( + document, + PreviewDocument { + origin: PreviewOrigin { + root: PathBuf::from("/worktrees/other"), + ..origin("/worktrees/feature") + }, + ..document.clone() + }, + "the root identity is part of a frozen document" + ); + assert_ne!( + document, + PreviewDocument { + origin: PreviewOrigin { + branch: BranchState::Detached, + ..origin("/worktrees/feature") + }, + ..document.clone() + }, + "the branch identity is part of a frozen document" + ); + } + + #[test] + fn preview_identity_is_the_root_identity_and_root_relative_path_pair() { + let original = origin("/worktrees/feature"); + let same_file = origin("/worktrees/feature"); + let another_file = PreviewOrigin { + root_relative_path: PathBuf::from("src/main.rs"), + absolute_path: PathBuf::from("/worktrees/feature/src/main.rs"), + ..origin("/worktrees/feature") + }; + + assert_eq!(original.identity(), same_file.identity()); + let same_file_with_a_different_absolute_path = PreviewOrigin { + absolute_path: PathBuf::from("/copied-preview/src/lib.rs"), + ..origin("/worktrees/feature") + }; + assert_eq!( + original.identity(), + same_file_with_a_different_absolute_path.identity(), + "absolute paths are retained for copying, not preview identity" + ); + assert_ne!(original.identity(), another_file.identity()); + + let detached = PreviewOrigin { + branch: BranchState::Detached, + ..origin("/worktrees/feature") + }; + assert_ne!(original.identity(), detached.identity()); + } + + #[test] + fn the_same_relative_path_in_two_roots_has_a_different_preview_identity() { + assert_ne!( + origin("/worktrees/feature-a").identity(), + origin("/worktrees/feature-b").identity() + ); + } + + #[test] + fn cloned_preview_interaction_state_is_equal_then_mutates_independently() { + let interaction = PreviewInteractionState { + vertical_scroll: 12, + horizontal_scroll: 3, + viewport_width: 80, + viewport_height: 22, + search: Some(SearchState { + query: "needle".into(), + matches: vec![Match { + line: 4, + start: 2, + end: 8, + }], + current: 0, + }), + selection: None, + }; + let mut copied = interaction.clone(); + + assert_eq!(copied, interaction); + + copied.vertical_scroll = 19; + copied.search.as_mut().unwrap().query = "changed".into(); + + assert_eq!(interaction.vertical_scroll, 12); + assert_eq!(interaction.search.as_ref().unwrap().query, "needle"); + assert_ne!(copied, interaction); + } +} diff --git a/src/preview_layout.rs b/src/preview_layout.rs new file mode 100644 index 00000000..fd3e1fa9 --- /dev/null +++ b/src/preview_layout.rs @@ -0,0 +1,362 @@ +//! Pure structural layout policy for the tree and preview surfaces. +//! +//! This module owns only frame chrome and structural regions. Content interiors and scrollbars are +//! deliberately left to the Presenter because they depend on the displayed content. + +use crate::config::TreePosition; +use ratatui::layout::{Constraint, Layout, Rect}; + +/// The structural focus used when a narrow layout must show exactly one region. +/// +/// This is intentionally separate from the Presenter's current two-way focus enum: the pinned +/// preview can be tested before the controller starts producing a pinned focus value. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PreviewFocus { + #[default] + Tree, + Pinned, + Active, +} + +/// Inputs to the responsive, structural layout policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LayoutInput { + pub area: Rect, + pub has_prompt: bool, + pub has_remote_status: bool, + pub has_pin: bool, + pub focus: PreviewFocus, + pub tree_hidden: bool, + pub preview_split_pct: u16, + pub tree_split_pct: u16, + pub tree_position: TreePosition, + pub tree_max_cols: u16, + pub tree_split_manual: bool, +} + +impl LayoutInput { + pub fn new(area: Rect) -> Self { + Self { + area, + has_prompt: false, + has_remote_status: false, + has_pin: false, + focus: PreviewFocus::Tree, + tree_hidden: false, + preview_split_pct: 50, + tree_split_pct: 40, + tree_position: TreePosition::Left, + tree_max_cols: u16::MAX, + tree_split_manual: false, + } + } + + pub fn with_prompt(mut self, has_prompt: bool) -> Self { + self.has_prompt = has_prompt; + self + } + + pub fn with_remote_status(mut self, has_remote_status: bool) -> Self { + self.has_remote_status = has_remote_status; + self + } + + pub fn with_pin(mut self, has_pin: bool) -> Self { + self.has_pin = has_pin; + self + } + + pub fn with_focus(mut self, focus: PreviewFocus) -> Self { + self.focus = focus; + self + } +} + +/// Structural regions for one frame. Divider regions mark a one-cell-wide boundary at the first +/// cell of the right-hand region; they are hit-test markers rather than additional drawable gaps. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PreviewLayout { + pub body: Rect, + pub prompt: Option, + pub remote_status: Option, + pub tree: Option, + pub pinned: Option, + pub active: Option, + pub tree_divider: Option, + pub preview_divider: Option, + /// A pin exists but its candidate region misses the 40-column interior floor. The tree and + /// active regions deliberately match the no-pin layout; the Presenter uses this signal to + /// add the persistent widen notice to the active projection. + pub pinned_hidden_for_space: bool, +} + +/// The existing fixed no-pin breakpoint. A pinned layout uses the structural preview floor below. +const NARROW_SPLIT: u16 = 80; +const PREVIEW_INTERIOR_FLOOR: u16 = 40; +const PREVIEW_BORDER_COLUMNS: u16 = 2; + +/// The smallest tree percentage that can provide the configured minimum number of tree columns. +/// Re-exported by `presenter` for its existing controller callers. +pub fn min_tree_split_pct(pane_width: u16) -> u16 { + if pane_width == 0 { + return crate::config::MIN_TREE_MAX_COLS; + } + let pct = (crate::config::MIN_TREE_MAX_COLS as u32 * 100).div_ceil(pane_width as u32) as u16; + pct.clamp(1, 40) +} + +/// Carve every structural region from `input.area`, saturating safely for tiny frames. +pub fn layout(input: LayoutInput) -> PreviewLayout { + let (body, remote_status, prompt) = + carve_chrome(input.area, input.has_prompt, input.has_remote_status); + let mut result = PreviewLayout { + body, + prompt, + remote_status, + ..PreviewLayout::default() + }; + if body.width == 0 || body.height == 0 { + return result; + } + + if !input.has_pin { + carve_unpinned(&mut result, input); + return result; + } + let (tree, preview_area, tree_divider) = if input.tree_hidden { + (None, body, None) + } else { + let (tree, preview, divider) = tree_and_preview(body, input); + (Some(tree), preview, Some(divider)) + }; + let ratio = input.preview_split_pct.clamp(20, 80); + // The session ratio continues to name the pinned share, now measured from the right edge. + let previews = Layout::horizontal([ + Constraint::Percentage(100 - ratio), + Constraint::Percentage(ratio), + ]) + .split(preview_area); + + if preview_is_wide_enough(previews[0]) && preview_is_wide_enough(previews[1]) { + result.tree = tree; + result.active = Some(previews[0]); + result.pinned = Some(previews[1]); + result.tree_divider = tree_divider.map(|x| divider_region(body, x)); + result.preview_divider = Some(divider_region(body, previews[1].x)); + } else { + // A hidden pin must never reflow or collapse the existing tree/active geometry. Reuse the + // no-pin carve exactly, including its established 80-column fallback behaviour. + carve_unpinned(&mut result, input); + result.pinned_hidden_for_space = true; + } + result +} + +fn carve_chrome( + area: Rect, + has_prompt: bool, + has_remote_status: bool, +) -> (Rect, Option, Option) { + let (above_prompt, prompt) = if has_prompt && area.height >= 2 { + let parts = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area); + (parts[0], Some(parts[1])) + } else { + (area, None) + }; + if !has_remote_status || above_prompt.height < 2 { + return (above_prompt, None, prompt); + } + let parts = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(above_prompt); + (parts[0], Some(parts[1]), prompt) +} + +fn carve_unpinned(result: &mut PreviewLayout, input: LayoutInput) { + if input.tree_hidden { + result.active = Some(result.body); + return; + } + if result.body.width < NARROW_SPLIT { + match input.focus { + PreviewFocus::Tree => result.tree = Some(result.body), + PreviewFocus::Pinned | PreviewFocus::Active => result.active = Some(result.body), + } + return; + } + let (tree, active, divider) = tree_and_preview(result.body, input); + result.tree = Some(tree); + result.active = Some(active); + result.tree_divider = Some(divider_region(result.body, divider)); +} + +fn tree_and_preview(area: Rect, input: LayoutInput) -> (Rect, Rect, u16) { + let tree_pct = input + .tree_split_pct + .clamp(min_tree_split_pct(area.width), 90); + let pct_cols = (area.width as u32 * tree_pct as u32 / 100) as u16; + let cap_bites = !input.tree_split_manual && pct_cols > input.tree_max_cols; + let (tree_constraint, preview_constraint) = if cap_bites { + (Constraint::Length(input.tree_max_cols), Constraint::Min(0)) + } else { + ( + Constraint::Percentage(tree_pct), + Constraint::Percentage(100 - tree_pct), + ) + }; + let columns = match input.tree_position { + TreePosition::Left => Layout::horizontal([tree_constraint, preview_constraint]).split(area), + TreePosition::Right => { + Layout::horizontal([preview_constraint, tree_constraint]).split(area) + } + }; + match input.tree_position { + TreePosition::Left => (columns[0], columns[1], columns[1].x), + TreePosition::Right => (columns[1], columns[0], columns[1].x), + } +} + +fn preview_is_wide_enough(area: Rect) -> bool { + area.width.saturating_sub(PREVIEW_BORDER_COLUMNS) >= PREVIEW_INTERIOR_FLOOR +} + +fn divider_region(body: Rect, x: u16) -> Rect { + Rect::new(x, body.y, 1, body.height) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tiny_frames_suppress_chrome_and_regions_safely() { + for (area, prompt, status) in [ + (Rect::new(0, 0, 0, 0), false, false), + (Rect::new(0, 0, 1, 1), true, true), + (Rect::new(0, 0, 1, 2), true, true), + ] { + let result = layout( + LayoutInput::new(area) + .with_prompt(prompt) + .with_remote_status(status) + .with_pin(true), + ); + if area.width == 0 || area.height == 0 { + assert_eq!(result.tree, None); + assert_eq!(result.pinned, None); + assert_eq!(result.active, None); + } else { + for region in [result.tree, result.pinned, result.active] { + assert!(region.is_none_or(|rect| rect.x >= area.x && rect.y >= area.y)); + } + } + } + } + + #[test] + fn pin_present_tiny_bodies_match_the_no_pin_layout() { + for area in [Rect::new(0, 0, 1, 1), Rect::new(0, 0, 1, 2)] { + let result = layout(LayoutInput::new(area).with_pin(true)); + let without_pin = layout(LayoutInput::new(area)); + assert_eq!(result.tree, without_pin.tree, "tree at {area:?}"); + assert_eq!(result.active, without_pin.active, "active at {area:?}"); + assert_eq!(result.pinned, None, "pinned preview at {area:?}"); + assert!(result.pinned_hidden_for_space, "notice signal at {area:?}"); + } + } + + #[test] + fn pin_split_floor_hides_only_the_pin_and_keeps_no_pin_geometry() { + for ratio in [20, 50, 80] { + for focus in [ + PreviewFocus::Tree, + PreviewFocus::Pinned, + PreviewFocus::Active, + ] { + let input = LayoutInput { + area: Rect::new(0, 0, 138, 10), + has_pin: true, + focus, + preview_split_pct: ratio, + tree_split_pct: 40, + tree_max_cols: u16::MAX, + ..LayoutInput::new(Rect::default()) + }; + let result = layout(input); + let without_pin = layout(LayoutInput { + has_pin: false, + ..input + }); + assert_eq!( + result.tree, without_pin.tree, + "ratio {ratio}, focus {focus:?}" + ); + assert_eq!( + result.active, without_pin.active, + "ratio {ratio}, focus {focus:?}" + ); + assert_eq!(result.tree_divider, without_pin.tree_divider); + assert_eq!(result.pinned, None, "ratio {ratio} hides the pin only"); + assert!( + result.pinned_hidden_for_space, + "ratio {ratio} signals the active notice" + ); + } + } + } + + #[test] + fn equal_preview_split_rounds_by_at_most_one_column() { + for width in [84, 85] { + let result = layout(LayoutInput { + area: Rect::new(0, 0, width, 10), + has_pin: true, + tree_hidden: true, + ..LayoutInput::new(Rect::default()) + }); + let pinned = result.pinned.unwrap(); + let active = result.active.unwrap(); + assert!(pinned.width.abs_diff(active.width) <= 1); + } + } + + #[test] + fn preview_ratio_is_clamped_and_preserved_across_widths() { + for (requested, expected) in [(0, 20), (20, 20), (50, 50), (80, 80), (100, 80)] { + for width in [210, 237, 300] { + let result = layout(LayoutInput { + area: Rect::new(0, 0, width, 10), + has_pin: true, + tree_hidden: true, + preview_split_pct: requested, + ..LayoutInput::new(Rect::default()) + }); + let pinned = result.pinned.unwrap(); + assert!( + (pinned.width as i32 - (width as i32 * expected / 100)).abs() <= 1, + "requested {requested}, width {width}" + ); + } + } + } + + #[test] + fn tree_side_only_changes_tree_placement_not_preview_order() { + for (position, tree_is_left) in [(TreePosition::Left, true), (TreePosition::Right, false)] { + let result = layout(LayoutInput { + area: Rect::new(10, 5, 220, 12), + has_pin: true, + tree_position: position, + ..LayoutInput::new(Rect::default()) + }); + let tree = result.tree.unwrap(); + let pinned = result.pinned.unwrap(); + let active = result.active.unwrap(); + assert_eq!(tree.x < pinned.x, tree_is_left); + assert!( + active.x < pinned.x, + "active stays left of the pinned preview" + ); + assert_eq!(result.tree_divider.unwrap().x, tree.x.max(active.x)); + assert_eq!(result.preview_divider.unwrap().x, pinned.x); + } + } +} diff --git a/tests/annotations.rs b/tests/annotations.rs index 6c462474..93857c6c 100644 --- a/tests/annotations.rs +++ b/tests/annotations.rs @@ -441,7 +441,7 @@ fn line_select_cancel_restores_the_exact_mouse_selection_snapshot() { ctrl.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 45, 2)); ctrl.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 49, 5)); ctrl.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 49, 5)); - let before_view = ctrl.view_state().line_select.unwrap(); + let before_view = ctrl.view_state().active.line_select.unwrap(); let before_marker = before_view.marker; let before = before_view.char_sel.unwrap(); let exact_before = ( @@ -460,7 +460,7 @@ fn line_select_cancel_restores_the_exact_mouse_selection_snapshot() { ); ctrl.handle_annotation_editor_key(key(KeyCode::Esc)); - let after_view = ctrl.view_state().line_select.unwrap(); + let after_view = ctrl.view_state().active.line_select.unwrap(); assert_eq!( after_view.marker, before_marker, "cancel restores the directed marker, not only the normalized span" diff --git a/tests/controller.rs b/tests/controller.rs index 6fca4c6d..0e5a555e 100644 --- a/tests/controller.rs +++ b/tests/controller.rs @@ -324,7 +324,7 @@ fn apply_hide_dotfiles_at_startup_re_renders_so_content_matches_the_new_selectio // Drain until content lands, then require the body to belong to the SAME file as the title. await_marker(&mut ctrl, "BODY-OF:"); assert_eq!( - ctrl.view_state().content_title.as_deref(), + ctrl.view_state().active.title.as_deref(), Some("keep.txt"), "the title reflects the visible selection" ); @@ -1288,7 +1288,7 @@ fn nav_does_not_scroll_content_while_the_tree_is_focused() { ctrl.handle(Intent::NavDown); ctrl.handle(Intent::NavDown); assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 0, "tree focus: content never scrolls" ); @@ -1304,18 +1304,18 @@ fn nav_scrolls_the_content_pane_when_focused_and_clamps_both_ends() { ctrl.handle(Intent::ToggleFocus); assert_eq!(ctrl.focus(), Focus::Content); - assert_eq!(ctrl.view_state().content_scroll, 0, "starts at the top"); + assert_eq!(ctrl.view_state().active.scroll, 0, "starts at the top"); ctrl.handle(Intent::NavDown); ctrl.handle(Intent::NavDown); assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 2, "NavDown scrolls the content down" ); ctrl.handle(Intent::NavUp); assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 1, "NavUp scrolls the content up" ); @@ -1324,7 +1324,7 @@ fn nav_scrolls_the_content_pane_when_focused_and_clamps_both_ends() { ctrl.handle(Intent::NavUp); } assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 0, "cannot scroll above the first line" ); @@ -1333,7 +1333,7 @@ fn nav_scrolls_the_content_pane_when_focused_and_clamps_both_ends() { ctrl.handle(Intent::NavDown); } assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 40, "cannot scroll past the last screenful" ); @@ -1356,15 +1356,15 @@ fn page_keys_scroll_the_content_pane_by_one_viewport_and_clamp() { ctrl.handle(Intent::PageDown); assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 10, "PageDown advances one content viewport" ); ctrl.handle(Intent::PageDown); - assert_eq!(ctrl.view_state().content_scroll, 20, "and another"); + assert_eq!(ctrl.view_state().active.scroll, 20, "and another"); ctrl.handle(Intent::PageUp); assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 10, "PageUp returns the same distance" ); @@ -1373,7 +1373,7 @@ fn page_keys_scroll_the_content_pane_by_one_viewport_and_clamp() { ctrl.handle(Intent::PageUp); } assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 0, "cannot page above the first line" ); @@ -1381,7 +1381,7 @@ fn page_keys_scroll_the_content_pane_by_one_viewport_and_clamp() { ctrl.handle(Intent::PageDown); } assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 40, "cannot page past the last screenful" ); @@ -1493,12 +1493,12 @@ fn selecting_a_different_file_resets_the_scroll_to_the_top() { for _ in 0..5 { ctrl.handle(Intent::NavDown); } - assert_eq!(ctrl.view_state().content_scroll, 5, "scrolled down"); + assert_eq!(ctrl.view_state().active.scroll, 5, "scrolled down"); ctrl.handle(Intent::ToggleFocus); // back to the tree ctrl.handle(Intent::NavDown); // select the next file assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 0, "a new selection resets the scroll" ); @@ -1515,13 +1515,16 @@ fn wrap_is_on_for_markdown_and_off_for_code() { ctrl_md.selected_view_mode(), Some(ViewMode::RenderedMarkdown) ); - assert!(ctrl_md.view_state().wrap, "markdown content wraps"); + assert!(ctrl_md.view_state().active.wrap, "markdown content wraps"); let rs = TempDir::new(); std::fs::write(rs.path().join("a.rs"), "fn main() {}\n").unwrap(); let (ctrl_rs, _, _) = controller(rs.path(), false, StubGit::default(), false); assert_eq!(ctrl_rs.selected_view_mode(), Some(ViewMode::SyntaxContent)); - assert!(!ctrl_rs.view_state().wrap, "code content does not wrap"); + assert!( + !ctrl_rs.view_state().active.wrap, + "code content does not wrap" + ); } #[test] @@ -1541,12 +1544,12 @@ fn content_pad_left_is_on_for_the_transformed_views_and_off_for_syntax() { "precondition: the selected file would render as markdown" ); assert!( - !ctrl_md.view_state().content_pad_left, + !ctrl_md.view_state().active.pad_left, "no gap until the body lands — the flag follows content_path, which is None pre-render" ); await_marker(&mut ctrl_md, "stub-content"); assert!( - ctrl_md.view_state().content_pad_left, + ctrl_md.view_state().active.pad_left, "rendered markdown is inset from the border once its body has landed" ); @@ -1555,7 +1558,7 @@ fn content_pad_left_is_on_for_the_transformed_views_and_off_for_syntax() { let (mut ctrl_rs, _, _) = controller(rs.path(), false, StubGit::default(), false); await_marker(&mut ctrl_rs, "stub-content"); assert!( - !ctrl_rs.view_state().content_pad_left, + !ctrl_rs.view_state().active.pad_left, "syntax content stays flush (bat's gutter already gaps it)" ); @@ -1573,13 +1576,13 @@ fn content_pad_left_is_on_for_the_transformed_views_and_off_for_syntax() { await_marker(&mut ctrl_diff, "stub-content"); assert_eq!(ctrl_diff.selected_view_mode(), Some(ViewMode::Diff)); assert!( - ctrl_diff.view_state().content_pad_left, + ctrl_diff.view_state().active.pad_left, "a diff is inset from the border" ); ctrl_diff.handle(Intent::CycleView); // Diff → FullDiff — still transformed, still inset await_marker(&mut ctrl_diff, "stub-content"); assert!( - ctrl_diff.view_state().content_pad_left, + ctrl_diff.view_state().active.pad_left, "the full-context diff is inset too" ); ctrl_diff.handle(Intent::CycleView); // FullDiff → SyntaxContent — gap drops @@ -1589,7 +1592,7 @@ fn content_pad_left_is_on_for_the_transformed_views_and_off_for_syntax() { Some(ViewMode::SyntaxContent) ); assert!( - !ctrl_diff.view_state().content_pad_left, + !ctrl_diff.view_state().active.pad_left, "cycling the diff to the syntax view drops the gap" ); } @@ -1602,13 +1605,16 @@ fn wrap_toggle_forces_wrapping_on_for_code_then_back_to_the_mode_default() { std::fs::write(rs.path().join("a.rs"), "fn main() {}\n").unwrap(); let (mut ctrl, _, _) = controller(rs.path(), false, StubGit::default(), false); - assert!(!ctrl.view_state().wrap, "code does not wrap by default"); + assert!( + !ctrl.view_state().active.wrap, + "code does not wrap by default" + ); let fx = ctrl.handle(Intent::ToggleWrap); assert!(fx.redraw); - assert!(ctrl.view_state().wrap, "`w` forces wrap on for code"); + assert!(ctrl.view_state().active.wrap, "`w` forces wrap on for code"); ctrl.handle(Intent::ToggleWrap); assert!( - !ctrl.view_state().wrap, + !ctrl.view_state().active.wrap, "toggling again returns to the mode default" ); } @@ -1621,11 +1627,14 @@ fn w_toggles_markdown_between_the_fit_and_the_wide_unwrapped_view() { let md = TempDir::new(); std::fs::write(md.path().join("a.md"), "# hi\n").unwrap(); let (mut ctrl, _, _) = controller(md.path(), false, StubGit::default(), false); - assert!(ctrl.view_state().wrap, "markdown fits (wraps) by default"); + assert!( + ctrl.view_state().active.wrap, + "markdown fits (wraps) by default" + ); ctrl.handle(Intent::ToggleWrap); assert!( - !ctrl.view_state().wrap, + !ctrl.view_state().active.wrap, "`w` switches markdown to the wide, unwrapped (horizontal-scroll) view" ); assert!( @@ -1635,7 +1644,7 @@ fn w_toggles_markdown_between_the_fit_and_the_wide_unwrapped_view() { ctrl.handle(Intent::ToggleWrap); assert!( - ctrl.view_state().wrap, + ctrl.view_state().active.wrap, "`w` again returns markdown to the fit (wrapped) view" ); } @@ -1651,16 +1660,16 @@ fn unwrapping_markdown_does_not_force_wrap_onto_a_code_file_viewed_next() { let (mut ctrl, _, _) = controller(dir.path(), false, StubGit::default(), false); assert!( - ctrl.view_state().wrap, + ctrl.view_state().active.wrap, "precondition: a.md wraps (fit view)" ); ctrl.handle(Intent::ToggleWrap); // unwrap markdown → force-off everywhere - assert!(!ctrl.view_state().wrap); + assert!(!ctrl.view_state().active.wrap); ctrl.handle(Intent::NavDown); // select z.rs (code) assert_eq!(ctrl.selected_view_mode(), Some(ViewMode::SyntaxContent)); assert!( - !ctrl.view_state().wrap, + !ctrl.view_state().active.wrap, "code stays unwrapped — the unwrap did not become a surprise wrap" ); } @@ -1693,29 +1702,29 @@ fn left_right_scroll_the_content_horizontally_when_focused_and_unwrapped() { await_marker(&mut ctrl, "WIDE"); ctrl.set_content_viewport(20, 10); // widest line 100, viewport 20 → max hscroll = 80 assert!( - !ctrl.view_state().wrap, + !ctrl.view_state().active.wrap, "a .rs file does not wrap, so horizontal scroll applies" ); ctrl.handle(Intent::ToggleFocus); // focus the content pane assert_eq!( - ctrl.view_state().content_hscroll, + ctrl.view_state().active.hscroll, 0, "starts at the left edge" ); let fx = ctrl.handle(Intent::Expand); // → scrolls right assert!(fx.redraw); - let after_one = ctrl.view_state().content_hscroll; + let after_one = ctrl.view_state().active.hscroll; assert!(after_one > 0, "→ scrolls the content right when focused"); ctrl.handle(Intent::Expand); assert!( - ctrl.view_state().content_hscroll > after_one, + ctrl.view_state().active.hscroll > after_one, "→ again scrolls further right" ); ctrl.handle(Intent::Collapse); // ← scrolls left assert_eq!( - ctrl.view_state().content_hscroll, + ctrl.view_state().active.hscroll, after_one, "← scrolls back left" ); @@ -1724,7 +1733,7 @@ fn left_right_scroll_the_content_horizontally_when_focused_and_unwrapped() { ctrl.handle(Intent::Collapse); } assert_eq!( - ctrl.view_state().content_hscroll, + ctrl.view_state().active.hscroll, 0, "cannot scroll left of the start" ); @@ -1732,7 +1741,7 @@ fn left_right_scroll_the_content_horizontally_when_focused_and_unwrapped() { ctrl.handle(Intent::Expand); } assert_eq!( - ctrl.view_state().content_hscroll, + ctrl.view_state().active.hscroll, 80, "clamps at the widest line minus the viewport" ); @@ -1765,7 +1774,7 @@ fn wrapped_content_scrolls_vertically_to_the_bottom_and_not_horizontally() { ); await_marker(&mut ctrl, "WIDE"); ctrl.set_content_viewport(25, 10); // 5 lines × ceil(100/25)=4 = 20 wrapped rows; max = 10 - assert!(ctrl.view_state().wrap, "a .md file wraps"); + assert!(ctrl.view_state().active.wrap, "a .md file wraps"); ctrl.handle(Intent::ToggleFocus); // focus content for _ in 0..500 { @@ -1773,16 +1782,16 @@ fn wrapped_content_scrolls_vertically_to_the_bottom_and_not_horizontally() { } // Wrapped rows (20) are counted, not raw lines (5, which would clamp to 0): the bottom is // reachable. Exact count via ratatui means no over-scroll into blank past row 20. - let vmax = ctrl.view_state().content_scroll; + let vmax = ctrl.view_state().active.scroll; assert_eq!( vmax, 10, "scrolls to exactly the last wrapped row (20 rows − 10 tall)" ); - let h_before = ctrl.view_state().content_hscroll; + let h_before = ctrl.view_state().active.hscroll; ctrl.handle(Intent::Expand); // → : would scroll right, but wrap leaves nothing to scroll past assert_eq!( - ctrl.view_state().content_hscroll, + ctrl.view_state().active.hscroll, h_before, "no horizontal scroll while wrapping" ); @@ -1802,14 +1811,14 @@ fn shrinking_the_viewport_reclamps_an_existing_scroll_offset() { ctrl.handle(Intent::NavDown); } assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 40, "scrolled to the bottom" ); ctrl.set_content_viewport(40, 30); // taller viewport → max 20; the offset must re-clamp assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 20, "offset re-clamped to the new, smaller max" ); @@ -1914,7 +1923,14 @@ fn wide_geometry() -> PaneGeometry { }), content_vbar: None, content_hbar: None, + pinned_inner: None, + pinned_title_rect: None, + pinned_vbar: None, + pinned_hbar: None, divider_x: Some(40), + preview_area_x: 0, + preview_area_width: 0, + preview_divider_x: None, finder_rows: None, finder_scroll: 0, finder_max_hscroll: 0, @@ -2186,14 +2202,14 @@ fn dragging_the_content_vertical_scrollbar_scrolls_the_content() { // Press at the bottom of the track (row 20 = track.y + height - 1) → max scroll. ctrl.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), vbar_col, 20)); assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 30, "pressing the bottom of the content vbar jumps to max scroll" ); // Drag to the top of the track → 0. ctrl.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), vbar_col, 1)); assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 0, "dragging the content vbar to the top scrolls back to 0" ); @@ -3145,11 +3161,11 @@ fn horizontal_wheel_scrolls_the_content_sideways() { ctrl.set_content_viewport(20, 10); // widest line 100, viewport 20 → max hscroll = 80 ctrl.set_pane_geometry(wide_geometry()); assert!( - !ctrl.view_state().wrap, + !ctrl.view_state().active.wrap, "a .rs file is unwrapped, so horizontal scroll applies" ); assert_eq!( - ctrl.view_state().content_hscroll, + ctrl.view_state().active.hscroll, 0, "starts at the left edge" ); @@ -3157,12 +3173,12 @@ fn horizontal_wheel_scrolls_the_content_sideways() { // Wheel right over the content column (no focus change needed — scroll what's under the cursor). ctrl.handle_mouse(mouse(MouseEventKind::ScrollRight, 50, 5)); assert!( - ctrl.view_state().content_hscroll > 0, + ctrl.view_state().active.hscroll > 0, "horizontal wheel-right scrolls the content right" ); ctrl.handle_mouse(mouse(MouseEventKind::ScrollLeft, 50, 5)); assert_eq!( - ctrl.view_state().content_hscroll, + ctrl.view_state().active.hscroll, 0, "wheel-left scrolls back to the start" ); @@ -3170,7 +3186,7 @@ fn horizontal_wheel_scrolls_the_content_sideways() { // Over the tree, horizontal wheel is inert (the tree has no horizontal scroll). ctrl.handle_mouse(mouse(MouseEventKind::ScrollRight, 5, 5)); assert_eq!( - ctrl.view_state().content_hscroll, + ctrl.view_state().active.hscroll, 0, "horizontal wheel over the tree does nothing" ); @@ -3227,7 +3243,7 @@ fn focus_gained_re_queries_git_but_preserves_content_scroll() { ctrl.handle(Intent::NavDown); ctrl.handle(Intent::NavDown); assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 2, "scrolled down two lines" ); @@ -3240,7 +3256,7 @@ fn focus_gained_re_queries_git_but_preserves_content_scroll() { "focus-gain re-queries git" ); assert_eq!( - ctrl.view_state().content_scroll, + ctrl.view_state().active.scroll, 2, "focus-gain does NOT reset the content scroll" ); diff --git a/tests/controller_async.rs b/tests/controller_async.rs index 17069b62..814f026f 100644 --- a/tests/controller_async.rs +++ b/tests/controller_async.rs @@ -13,6 +13,8 @@ use herdr_file_viewer::controller::{ }; use herdr_file_viewer::git::{Baseline, Status}; use herdr_file_viewer::intent::Intent; +use herdr_file_viewer::presenter::Focus; +use herdr_file_viewer::preview::BranchState; use herdr_file_viewer::view_policy::ViewMode; use ratatui::text::Text; use std::collections::BTreeMap; @@ -285,6 +287,109 @@ fn flatten(text: &Text) -> String { .join("\n") } +#[test] +fn an_applied_render_exposes_one_complete_active_preview_document() { + struct CompleteContent; + impl ContentProvider for CompleteContent { + fn render(&self, path: &Path, mode: ViewMode, _raw_diff: Option<&str>) -> RenderResult { + let body = format!( + "rendered:{}:{mode:?}", + path.file_name().unwrap().to_string_lossy() + ); + RenderResult { + content: Text::raw(body.clone()), + notices: vec!["bounded fallback notice".into()], + source: Some(vec![body]), + } + } + } + + let dir = TempDir::new(); + let path = dir.path().join("a.rs"); + std::fs::write(&path, "fn main() {}\n").unwrap(); + let components = Components { + providers: Box::new(|_resolved| RootProviders { + git: Arc::new(NoGit), + content: Box::new(CompleteContent), + }), + editor: Box::new(NoEditor), + clipboard: Box::new(common::RecordingClipboard::default()), + renderers: None, + }; + let mut ctrl = Controller::new( + common::resolved(dir.path().to_path_buf(), false), + Baseline::Head, + components, + ); + await_contains(&mut ctrl, "rendered:a.rs:SyntaxContent"); + + let document = ctrl + .active_document() + .expect("a settled file render is one complete preview document"); + assert_eq!(flatten(document.content()), "rendered:a.rs:SyntaxContent"); + assert_eq!(document.notices(), ["bounded fallback notice"]); + assert_eq!( + document.source(), + Some(["rendered:a.rs:SyntaxContent".to_string()].as_slice()) + ); + assert_eq!(document.presentation().view_mode(), ViewMode::SyntaxContent); + assert!(!document.presentation().wrap()); + assert!(!document.presentation().pad_left()); + assert_eq!(document.origin().root(), dir.path()); + assert_eq!(document.origin().branch(), &BranchState::Detached); + assert_eq!(document.origin().absolute_path(), path); + assert_eq!(document.origin().root_relative_path(), Path::new("a.rs")); +} + +#[test] +fn pinning_while_the_new_selection_is_rendering_rejects_without_capturing_it() { + let dir = TempDir::new(); + std::fs::write(dir.path().join("a.rs"), "1\n").unwrap(); + std::fs::write(dir.path().join("b.rs"), "2\n").unwrap(); + let components = Components { + providers: Box::new(move |_resolved| RootProviders { + git: Arc::new(NoGit), + content: Box::new(SlowContent { + delay: Duration::from_millis(80), + }), + }), + editor: Box::new(NoEditor), + clipboard: Box::new(common::RecordingClipboard::default()), + renderers: None, + }; + let mut ctrl = Controller::new( + common::resolved(dir.path().to_path_buf(), false), + Baseline::Head, + components, + ); + await_contains(&mut ctrl, "rendered:a.rs"); + ctrl.pin_active_preview(); + let original = ctrl + .view_state() + .pinned + .expect("settled initial selection was pinned") + .origin + .expect("pin retains initial identity"); + + ctrl.handle(Intent::NavDown); + assert!( + ctrl.view_state().active.rendering, + "b.rs render is in flight" + ); + assert!(ctrl.pin_active_preview().redraw); + assert_eq!( + ctrl.action_notice(), + Some("Cannot pin while preview is rendering") + ); + assert_eq!( + ctrl.view_state() + .pinned + .expect("rejection keeps existing pin") + .origin, + Some(original) + ); +} + #[test] fn a_select_intent_does_not_block_on_a_slow_render_and_content_arrives_later() { let dir = TempDir::new(); @@ -449,6 +554,14 @@ fn a_superseded_render_does_not_overwrite_a_newer_selection() { "rendered:c.rs", "a superseded render must not overwrite the newer selection" ); + let document = ctrl + .active_document() + .expect("the latest settled file remains the active document"); + assert_eq!( + document.origin().root_relative_path(), + Path::new("c.rs"), + "a stale result cannot replace the active document's captured identity" + ); } #[test] @@ -546,7 +659,7 @@ fn a_slow_render_shows_a_loading_placeholder_and_switches_title_with_body() { } // Precondition: a.rs is the displayed file — its name is the content title. assert_eq!( - ctrl.view_state().content_title.as_deref(), + ctrl.view_state().active.title.as_deref(), Some("a.rs"), "precondition: a.rs content landed, title is a.rs" ); @@ -575,7 +688,7 @@ fn a_slow_render_shows_a_loading_placeholder_and_switches_title_with_body() { // (b) The title has NOT jumped to b.rs ahead of its body — it still names the displayed // content's file (a.rs). assert_eq!( - ctrl.view_state().content_title.as_deref(), + ctrl.view_state().active.title.as_deref(), Some("a.rs"), "the content title does not update ahead of the body — it stays on the displayed file \ (a.rs) until b.rs's render lands" @@ -599,7 +712,7 @@ fn a_slow_render_shows_a_loading_placeholder_and_switches_title_with_body() { "the selected file's rendered content arrived" ); assert_eq!( - ctrl.view_state().content_title.as_deref(), + ctrl.view_state().active.title.as_deref(), Some("b.rs"), "the title switched to b.rs together with its body" ); @@ -645,7 +758,7 @@ fn the_left_gap_follows_the_displayed_file_not_the_selection_during_a_slow_rende std::thread::sleep(Duration::from_millis(5)); } assert!( - !ctrl.view_state().content_pad_left, + !ctrl.view_state().active.pad_left, "precondition: the displayed code file has no gap" ); @@ -658,7 +771,7 @@ fn the_left_gap_follows_the_displayed_file_not_the_selection_during_a_slow_rende "precondition: b.md's render is in flight" ); assert!( - !ctrl.view_state().content_pad_left, + !ctrl.view_state().active.pad_left, "the gap does not flip on ahead of the body — it tracks the displayed a.rs, not selected b.md" ); @@ -673,7 +786,7 @@ fn the_left_gap_follows_the_displayed_file_not_the_selection_during_a_slow_rende std::thread::sleep(Duration::from_millis(5)); } assert!( - ctrl.view_state().content_pad_left, + ctrl.view_state().active.pad_left, "once the markdown body lands, the gap is on" ); } @@ -919,6 +1032,8 @@ fn a_width_reflow_preserves_scroll_and_recomputes_a_committed_search() { ctrl.scroll_to_line(20); let scrolled = ctrl.content_scroll(); assert!(scrolled > 0, "precondition: scrolled away from the top"); + let before_document = ctrl.active_document().unwrap().clone(); + let before_search = ctrl.search().cloned(); // Resize narrower (a width change) → reflow. Scroll and the committed search must survive. ctrl.set_content_viewport(30, 10); @@ -936,6 +1051,94 @@ fn a_width_reflow_preserves_scroll_and_recomputes_a_committed_search() { !search.matches.is_empty(), "the search is recomputed against the reflowed content" ); + let after_document = ctrl.active_document().unwrap(); + assert_ne!( + after_document.content(), + before_document.content(), + "the reflow replaces the active document's rendered body" + ); + assert_eq!( + after_document.origin(), + before_document.origin(), + "a width reflow retains the render job's captured file identity" + ); + assert_eq!( + ctrl.search().map(|state| state.query.as_str()), + before_search.as_ref().map(|state| state.query.as_str()), + "the active interaction's committed query survives document replacement" + ); +} + +/// A width reflow lands for the ACTIVE preview even while a Search prompt is open on the PINNED +/// preview, and the active side's committed search must still be recomputed against the reflowed +/// body. `refresh_search` routes by `prompt_target`, so a pinned prompt speaks only for the frozen +/// pinned snapshot; taking that branch for an active reflow left the active matches computed against +/// the pre-reflow rendered lines (wrong highlights, `n`/`N` jumping to stale rows). +/// +/// The ordering is forced, not raced: `set_content_viewport` dispatches the reflow synchronously, +/// the pinned prompt is opened before any `poll`, and only then is the result drained — so the +/// result provably lands with the pinned prompt open. +#[test] +fn a_width_reflow_recomputes_the_active_search_while_a_pinned_search_prompt_is_open() { + let dir = TempDir::new(); + std::fs::write(dir.path().join("doc.md"), "# hi\n").unwrap(); + let widths = Arc::new(Mutex::new(Vec::new())); + let mut ctrl = Controller::new( + common::resolved(dir.path().to_path_buf(), false), + Baseline::Head, + width_probe_components(Arc::clone(&widths), 50), + ); + await_contains(&mut ctrl, "w=None:doc.md"); + ctrl.set_content_viewport(40, 10); + await_contains(&mut ctrl, "w=Some(40):doc.md"); + + // Pin the settled active preview, then commit an ACTIVE search for a token that exists only in + // the width-40 body, so a recompute is observably different from carrying the stale match over. + ctrl.pin_active_preview(); + ctrl.handle(Intent::OpenSearch); + for c in "Some(40)".chars() { + ctrl.handle_prompt_key(key(KeyCode::Char(c))); + } + ctrl.handle_prompt_key(key(KeyCode::Enter)); + assert_eq!( + ctrl.search().map(|s| s.matches.len()), + Some(1), + "precondition: the committed active search matches the width-40 body once" + ); + + // Dispatch the reflow, THEN open a Search prompt on the pinned preview, and only then drain. + ctrl.set_content_viewport(30, 10); + // Cycle the focus ring (Tree → Content → Pinned) rather than assuming where a committed search + // left it; the ring is bounded, so a full lap proves the pinned region is focusable at all. + for _ in 0..3 { + if ctrl.focus() == Focus::Pinned { + break; + } + ctrl.handle(Intent::ToggleFocus); + } + assert_eq!( + ctrl.focus(), + Focus::Pinned, + "precondition: focus moved to the pinned preview" + ); + ctrl.handle(Intent::OpenSearch); + for c in "line".chars() { + ctrl.handle_prompt_key(key(KeyCode::Char(c))); + } + await_contains(&mut ctrl, "w=Some(30):doc.md"); + + let active = ctrl + .active_interaction() + .search + .as_ref() + .expect("the active committed search survives the reflow"); + assert_eq!(active.query, "Some(40)", "the active query is untouched"); + assert!( + active.matches.is_empty(), + "the active search must be recomputed against the reflowed body, not left pointing at \ + pre-reflow rendered lines: {:?}", + active.matches + ); } /// A resize only reflows rendered markdown — a non-markdown view (code / plain text) is diff --git a/tests/docs_consistency.rs b/tests/docs_consistency.rs index 01da17f1..3b8baa82 100644 --- a/tests/docs_consistency.rs +++ b/tests/docs_consistency.rs @@ -20,6 +20,7 @@ const CONFIG_EXAMPLE: &str = include_str!("../config.example.toml"); const USAGE_DOC: &str = include_str!("../docs/usage.md"); const INSTALL_DOC: &str = include_str!("../docs/install.md"); const SECURITY: &str = include_str!("../SECURITY.md"); +const ARCHITECTURE: &str = include_str!("../ARCHITECTURE.md"); const AGENT_SKILL: &str = include_str!("../skills/herdr-file-viewer/SKILL.md"); const OPEN_PANE_SCRIPT: &str = include_str!("../scripts/open-file-viewer.sh"); const OPEN_TAB_SCRIPT: &str = include_str!("../scripts/open-file-viewer-tab.sh"); @@ -225,6 +226,25 @@ fn keys_doc_documents_line_select_key() { ); } +#[test] +fn pinned_preview_resize_docs_describe_horizontal_space() { + // The previews sit side-by-side. `{` / `}` move their shared horizontal divider; wording them + // as vertical space would describe a different layout and mislead users configuring the keys. + assert!( + KEYS_DOC.contains("shrink / grow its horizontal share"), + "docs/keys.md must describe pinned-preview resizing as changing its horizontal share" + ); + for description in [ + "Give the pinned preview less horizontal space", + "Give the pinned preview more horizontal space", + ] { + assert!( + CONFIG_DOC.contains(description), + "docs/configuration.md must describe pinned-preview resizing as changing horizontal space: {description}" + ); + } +} + #[test] fn keys_doc_documents_reveal_open_keys() { assert!( @@ -430,3 +450,65 @@ fn changelog_documents_line_reference_release() { "the `## [1.9.0]` `### Added` block must document the copy-line-reference feature" ); } + +#[test] +fn pinned_preview_docs_cover_the_frozen_reference_contract() { + // AC-40: pinning is deliberately more than a `p` row. Its session lifetime, captured origin, + // focus routing, independent interaction state, narrow-layout floor, and read-only boundary + // must stay discoverable across the feature guide, key/config reference, architecture map, and + // Unreleased changelog entry. + for phrase in [ + "## Pinned previews", + "frozen in-memory snapshot", + "captured origin", + "survives a worktree switch", + "tree → active preview → pinned preview → tree", + "Pinned: — widen to view", + "Confirming from a pinned preview moves focus to the active preview", + "scroll positions and searches are independent", + "unavailable from the pinned preview", + "captured repo-relative path", + "captured absolute path", + "40-column floor", + "the hidden pin persists", + "preview divider", + "{` / `}`", + "drag the preview divider", + ] { + assert!( + USAGE_DOC.contains(phrase), + "docs/usage.md must document pinned-preview contract detail: {phrase:?}" + ); + } + + for phrase in ["`p`", "`{` / `}`", "Shift", "AltGr", "preview divider"] { + assert!( + KEYS_DOC.contains(phrase), + "docs/keys.md must document pinned-preview key detail: {phrase:?}" + ); + } + for phrase in [ + "pin_preview", + "shrink_preview", + "grow_preview", + "Shift", + "AltGr", + ] { + assert!( + CONFIG_DOC.contains(phrase), + "docs/configuration.md must document pinned-preview remapping detail: {phrase:?}" + ); + } + for module in ["`preview`", "`preview_layout`", "`focus_policy`"] { + assert!( + ARCHITECTURE.contains(module), + "ARCHITECTURE.md must map the pinned-preview module: {module}" + ); + } + + let unreleased = section(CHANGELOG, "## [Unreleased]", "\n## ["); + assert!( + unreleased.contains("Pinned preview"), + "CHANGELOG.md must include the pinned-preview feature in Unreleased" + ); +} diff --git a/tests/e2e_keyboard.rs b/tests/e2e_keyboard.rs index 2ffd4222..b49a537d 100644 --- a/tests/e2e_keyboard.rs +++ b/tests/e2e_keyboard.rs @@ -127,8 +127,14 @@ fn every_keyboard_function_drives_the_viewer_and_it_exits_cleanly() { std::fs::write(p.join("aaa.txt"), "ALPACAMARK\n").unwrap(); git(p, &["add", "aaa.txt", "subdir/grand.txt"]); git(p, &["commit", "-q", "-m", "files"]); - // An untracked file so the changed-only / baseline keys have something to act on. - std::fs::write(p.join("bbb.txt"), "BRAVO\n").unwrap(); + // Two tracked files, changed after their commit, give `[` / `]` two stable targets. Keeping + // them tracked makes both the Head and Base changed-set paths exercise the same candidates. + std::fs::write(p.join("bbb.txt"), "bravo base\n").unwrap(); + std::fs::write(p.join("ccc.txt"), "charlie base\n").unwrap(); + git(p, &["add", "bbb.txt", "ccc.txt"]); + git(p, &["commit", "-q", "-m", "changed-file candidates"]); + std::fs::write(p.join("bbb.txt"), "BRAVOMARK\n").unwrap(); + std::fs::write(p.join("ccc.txt"), "CHARLIEMARK\n").unwrap(); // A trivial, instantly-exiting "editor" so the open-in-editor key is safe to drive here. let mut cmd = viewer_command(p); @@ -151,14 +157,36 @@ fn every_keyboard_function_drives_the_viewer_and_it_exits_cleanly() { s.send("k").expect("send nav-up to the directory"); s.send("h").expect("send collapse on the directory"); + // The collapsed-directory cursor is still on the directory. Move once onto a file before + // exercising pinned-preview controls, so p has an eligible settled preview and [`/`] changed + // navigation below can retarget only the active selection rather than the frozen snapshot. + s.send("j") + .expect("send nav-down onto a file after collapse"); + s.expect("ALPACAMARK") + .expect("the file below the collapsed directory is active and rendered"); + + // The default PTY is too narrow for two 40-column preview interiors. Pinning preserves the + // tree/active layout and reports the held snapshot instead of focusing an undrawn pane. + s.send("p").expect("pin the settled active preview"); + s.expect("Pinned: aaa.txt — widen to view") + .expect("the hidden pin has a persistent widen notice"); + s.send("]").expect("jump forward to the next changed file"); + s.expect("BRAVOMARK") + .expect("the forward changed-file jump updated the active preview"); + s.send("[") + .expect("jump backward to the previous changed file"); + s.expect("CHARLIEMARK") + .expect("the backward changed-file jump updated the active preview"); + // Drive every remaining keyboard function; each must be wired and must not crash the loop. for key in [ - "i", // toggle ignored - "c", // changed-only - "b", // toggle baseline - "v", // cycle view - "\t", // toggle focus - "e", // open-in-editor (hands off to `true`, which exits immediately) + "i", // toggle ignored + "c", // changed-only + "b", // toggle baseline + "v", // cycle view + "{", // shrink the pinned-preview share + "}", // grow the pinned-preview share + "e", // open-in-editor (hands off to `true`, which exits immediately) ] { s.send(key).expect("send key"); } diff --git a/tests/e2e_pinned_preview.rs b/tests/e2e_pinned_preview.rs new file mode 100644 index 00000000..dff4012f --- /dev/null +++ b/tests/e2e_pinned_preview.rs @@ -0,0 +1,246 @@ +//! e2e (pty): pinned previews are session-only and every interaction remains read-only. +//! +//! Unix-only: see `tests/cli_smoke.rs` for why the `expectrl` pty suite is not ported to +//! Windows's `conpty` backend in this feature. +#![cfg(unix)] + +mod common; + +use common::{TempDir, git, init_repo_with_commit, viewer_command, workspace_fingerprint}; +use expectrl::process::unix::WaitStatus; +use expectrl::{Eof, Expect, Session}; +use std::time::Duration; + +/// AC-13 / AC-36 / AC-38 / AC-39: a real viewer session can pin, focus and search the frozen +/// preview, resize it from the keyboard, cross worktrees, and unpin without changing either +/// workspace or its git state. A fresh viewer then proves the pin was not persisted. +#[test] +fn pinned_preview_journey_is_read_only_and_does_not_outlive_its_process() { + // Linked worktrees must be siblings: `git worktree add` requires its target not to exist. + let main_dir = TempDir::new(); + let feature_dir = TempDir::new(); + let main = main_dir.path(); + let feature = feature_dir.path().join("feature-worktree"); + init_repo_with_commit(main); + + // This filename sorts before seed.txt, making it the launch selection in the main worktree. + // The distinctive body is the completed-render barrier before we ask the controller to pin. + std::fs::write( + main.join("PINNED.txt"), + "PINNED_TOP\nPINNED_SEARCH_MARKER\n", + ) + .expect("write pin candidate"); + git(main, &["add", "PINNED.txt"]); + git( + main, + &["commit", "-q", "-m", "add pinned-preview candidate"], + ); + + git( + main, + &[ + "worktree", + "add", + "-b", + "pinned-preview-feature", + feature.to_str().expect("utf-8 worktree path"), + ], + ); + // Its unique body lets the post-unpin focus cycle prove that the active preview, rather than + // a retained frozen pin, receives search input. + std::fs::write( + main.join("UNPIN_TARGET.txt"), + "UNPIN_ACTIVE_SEARCH_MARKER\n", + ) + .expect("write post-unpin target"); + git(main, &["add", "UNPIN_TARGET.txt"]); + git(main, &["commit", "-q", "-m", "add post-unpin target"]); + // FEATURE.txt sorts before PINNED.txt in the feature worktree, which supplies a visible + // outcome anchor after the switch without modifying the main worktree during the journey. + std::fs::write(feature.join("FEATURE.txt"), "FEATURE_WORKTREE_MARKER\n") + .expect("write feature-only file"); + git(&feature, &["add", "FEATURE.txt"]); + git(&feature, &["commit", "-q", "-m", "add feature marker"]); + + // Capture both roots. `workspace_fingerprint` includes every non-.git byte, HEAD, + // porcelain status, and `git worktree list --porcelain`; it deliberately stays unchanged. + let main_before = workspace_fingerprint(main); + let feature_before = workspace_fingerprint(&feature); + + let mut command = viewer_command(main); + command.env("EDITOR", "true"); + let mut session = Session::spawn(command).expect("spawn the viewer in a pty"); + session.set_expect_timeout(Some(Duration::from_secs(15))); + + session + .expect("PINNED_TOP") + .expect("main file has rendered before pinning"); + session.send("p").expect("pin the settled active preview"); + + // The default PTY is intentionally narrow. Pinning retains the tree/active layout and gives + // an observable persistent notice instead of focusing an undrawn pin. + session + .expect("Pinned: PINNED.txt — widen to view") + .expect("a hidden pin explains how to reveal it"); + session + .send("\t") + .expect("focus the visible active preview"); + session.send("/").expect("open active-preview search"); + session + .expect("Search:") + .expect("active-preview search prompt is visible"); + for key in "PINNED_SEARCH_MARKER".chars() { + session + .send(key.to_string()) + .expect("type pinned search query"); + } + session + .expect("(1/1)") + .expect("the visible active preview owns the search state"); + session.send("\r").expect("commit active search"); + session.send("\t").expect("cycle active focus to tree"); + session + .send("\t") + .expect("cycle tree focus back to active preview"); + + // `{` and `}` must resize only the preview divider. They are deliberately driven via the + // keyboard: this end-to-end journey must never inject a mouse event. + session.send("{").expect("shrink pinned preview share"); + session.send("}").expect("grow pinned preview share"); + + // Worktree switching remains available with a hidden pin. The picker begins on the current + // main worktree; j chooses its one linked sibling. The feature marker proves the re-root. + session + .send("W") + .expect("open worktree picker with a hidden pin"); + session + .expect("Switch worktree") + .expect("worktree picker is visible"); + session.send("j").expect("select feature worktree"); + session.send("\r").expect("switch to feature worktree"); + // A re-root resets focus to the tree. The next Tab reaches the sole visible active preview. + session + .send("\t") + .expect("focus active preview after re-root"); + session + .expect("FEATURE_WORKTREE_MARKER") + .expect("active preview followed the re-root while the pin remained held"); + // The re-root redraw can arrive with the active result; snapshot persistence is asserted by + // controller tests, while this journey continues without assuming another pane's visibility. + + // Return to the original root. The picker starts on the feature row, so k selects main. + session.send("W").expect("open worktree picker to return"); + session.send("k").expect("select original main worktree"); + session + .send("\r") + .expect("return to the pin's origin worktree"); + session.send("\t").expect("focus returned active preview"); + session + .expect("PINNED_TOP") + .expect("main active preview returned to the pin's original file"); + // The active result above is the observed return-to-main anchor; do not send a key for an + // undrawn pin before unpinning the active file. + session.send("p").expect("unpin the same active file"); + // With the pin gone, focus cycles Content -> Tree -> active, with no retained hidden region. + // "UNPIN" uniquely selects UNPIN_TARGET.txt in the current main worktree. + session.send("\t").expect("focus tree after unpinning"); + session + .send("f") + .expect("open finder for the post-unpin active file"); + session + .expect("Go to file") + .expect("the post-unpin finder is visible"); + for key in "UNPIN".chars() { + session + .send(key.to_string()) + .expect("type the distinct post-unpin finder query"); + } + session + .expect("UNPIN_TARGET.txt") + .expect("the finder selected the distinct post-unpin active file"); + session + .send("\r") + .expect("confirm the distinct post-unpin active file"); + session + .expect("UNPIN_ACTIVE_SEARCH_MARKER") + .expect("the tree selection updated the active preview after unpinning"); + // AC-41: finder confirmation already moved focus to this active preview. + session + .send("/") + .expect("open search in the post-unpin active preview"); + session + .expect("Search:") + .expect("the post-unpin active-preview search prompt is visible"); + for key in "UNPIN_ACTIVE_SEARCH_MARKER".chars() { + session + .send(key.to_string()) + .expect("type the active-only post-unpin search query"); + } + session + .expect("(1/1)") + .expect("unpin removed the former pin: the focus cycle now searches the active file"); + session.send("\r").expect("commit post-unpin active search"); + session + .expect("Esc clear") + .expect("the post-unpin search committed before dismissal and close"); + session + .send("\x1b") + .expect("dismiss post-unpin active search"); + // A bare ESC must be separated from the next byte or crossterm may decode the pair as Alt+q. + // This is input framing, not a viewer-settling wait. + std::thread::sleep(Duration::from_millis(150)); + // Send both close keys in one write. In the ordinary unzoomed journey the first quits and the + // queued second byte is discarded; if a zoom-state perturbation is added above, the first + // peels zoom and the second quits. Neither case relies on an unreliable runtime state probe. + session + .send("qq") + .expect("close the viewer regardless of the zoom layer"); + session.expect(Eof).expect("the journey exits cleanly"); + match session.get_process().wait().expect("reap journey viewer") { + WaitStatus::Exited(_, 0) => {} + other => panic!("expected a clean journey exit, got {other:?}"), + } + + assert_eq!( + workspace_fingerprint(main), + main_before, + "the pinned-preview journey must not alter the main worktree or git state" + ); + assert_eq!( + workspace_fingerprint(&feature), + feature_before, + "the pinned-preview journey must not alter the feature worktree or git state" + ); + + // A fresh process has no pin. Pressing p must create (not remove) a fresh snapshot, giving + // us a behaviorally observable, process-lifetime assertion without inspecting internals. + let mut fresh_command = viewer_command(main); + fresh_command.env("EDITOR", "true"); + let mut fresh = Session::spawn(fresh_command).expect("spawn fresh viewer in a pty"); + fresh.set_expect_timeout(Some(Duration::from_secs(15))); + fresh + .expect("PINNED_TOP") + .expect("fresh process rendered the main file"); + fresh.send("p").expect("pin in fresh process"); + fresh + .expect("Pinned: PINNED.txt — widen to view") + .expect("AC-13: fresh process started without a persisted pin"); + fresh.send("q").expect("close fresh viewer"); + fresh.expect(Eof).expect("fresh viewer exits cleanly"); + match fresh.get_process().wait().expect("reap fresh viewer") { + WaitStatus::Exited(_, 0) => {} + other => panic!("expected a clean fresh-process exit, got {other:?}"), + } + + // Best-effort cleanup before TempDir drops the repository that owns the linked worktree. + let _ = std::process::Command::new("git") + .arg("-C") + .arg(main) + .args([ + "worktree", + "remove", + "--force", + feature.to_str().expect("utf-8 worktree path"), + ]) + .output(); +} diff --git a/tests/lineselect.rs b/tests/lineselect.rs index 71d130f1..c2eea4d0 100644 --- a/tests/lineselect.rs +++ b/tests/lineselect.rs @@ -972,7 +972,10 @@ fn drag_populates_char_selection_in_the_view() { ctrl.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 45, 2)); let vs = ctrl.view_state(); - let ls = vs.line_select.expect("line-select overlay is present"); + let ls = vs + .active + .line_select + .expect("line-select overlay is present"); let cs = ls .char_sel .expect("a mouse drag populates the character selection for the overlay"); @@ -1283,7 +1286,7 @@ fn ambient_drag_auto_copies_on_release() { ctrl.notices() ); assert!( - ctrl.view_state().content_selection.is_some(), + ctrl.view_state().active.selection.is_some(), "the selection stays highlighted after the auto-copy (feedback)" ); } @@ -1302,11 +1305,12 @@ fn ambient_drag_populates_content_selection_overlay() { let vs = ctrl.view_state(); assert!( - vs.line_select.is_none(), + vs.active.line_select.is_none(), "the L-mode overlay is not used for an ambient drag" ); let cs = vs - .content_selection + .active + .selection .expect("the ambient selection overlay is populated"); assert_eq!( (cs.start_line, cs.start_col, cs.end_line, cs.end_col), @@ -1335,7 +1339,7 @@ fn ambient_plain_click_focuses_and_copies_nothing() { "a content click focuses the content pane" ); assert!( - ctrl.view_state().content_selection.is_none(), + ctrl.view_state().active.selection.is_none(), "a plain click leaves no standing selection" ); assert!( @@ -1354,14 +1358,14 @@ fn ambient_press_outside_content_clears_selection() { setup_ambient(&mut ctrl); ambient_drag_line1_to_line2(&mut ctrl); assert!( - ctrl.view_state().content_selection.is_some(), + ctrl.view_state().active.selection.is_some(), "precondition: a selection stands" ); let fx = ctrl.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 5, 3)); assert!(fx.redraw, "clearing the selection redraws"); assert!( - ctrl.view_state().content_selection.is_none(), + ctrl.view_state().active.selection.is_none(), "a press outside the content region clears the standing selection" ); } @@ -1376,7 +1380,7 @@ fn ambient_esc_clears_selection_before_quitting() { setup_ambient(&mut ctrl); ambient_drag_line1_to_line2(&mut ctrl); assert!( - ctrl.view_state().content_selection.is_some(), + ctrl.view_state().active.selection.is_some(), "precondition: a selection stands" ); @@ -1387,7 +1391,7 @@ fn ambient_esc_clears_selection_before_quitting() { "the first Esc dismisses the selection, it does not quit" ); assert!( - ctrl.view_state().content_selection.is_none(), + ctrl.view_state().active.selection.is_none(), "Esc clears the ambient selection" ); } @@ -1402,13 +1406,13 @@ fn ambient_selection_cleared_on_content_change() { setup_ambient(&mut ctrl); ambient_drag_line1_to_line2(&mut ctrl); assert!( - ctrl.view_state().content_selection.is_some(), + ctrl.view_state().active.selection.is_some(), "precondition: a selection stands" ); ctrl.handle(Intent::CycleView); assert!( - ctrl.view_state().content_selection.is_none(), + ctrl.view_state().active.selection.is_none(), "a content change (view cycle → dispatch_render) clears the selection" ); } @@ -1425,7 +1429,7 @@ fn ambient_shift_drag_is_left_for_the_terminal() { let fx = ctrl.handle_mouse(shift_mouse(MouseEventKind::Down(MouseButton::Left), 45, 3)); assert!(!fx.redraw, "a Shift+press is inert (left for the terminal)"); assert!( - ctrl.view_state().content_selection.is_none(), + ctrl.view_state().active.selection.is_none(), "Shift+mouse must not start an ambient selection" ); } @@ -1440,7 +1444,7 @@ fn entering_line_select_clears_an_ambient_selection() { setup_ambient(&mut ctrl); ambient_drag_line1_to_line2(&mut ctrl); assert!( - ctrl.view_state().content_selection.is_some(), + ctrl.view_state().active.selection.is_some(), "precondition: a selection stands" ); @@ -1448,7 +1452,7 @@ fn entering_line_select_clears_an_ambient_selection() { assert!(ctrl.line_select_active(), "L mode is now active"); let vs = ctrl.view_state(); assert!( - vs.content_selection.is_none(), + vs.active.selection.is_none(), "the ambient selection is cleared on entering L mode" ); assert_eq!( @@ -1511,6 +1515,7 @@ fn held_ambient_press_does_not_leak_into_line_select() { ); let ls = ctrl .view_state() + .active .line_select .expect("L-mode overlay present"); assert!( @@ -1539,7 +1544,7 @@ fn ambient_drag_over_rendering_placeholder_copies_nothing() { ctrl.set_content_viewport(80, 20); ctrl.set_pane_geometry(content_geometry()); assert!( - ctrl.view_state().content_rendering, + ctrl.view_state().active.rendering, "precondition: a render is in flight (the placeholder is shown)" ); @@ -1548,7 +1553,7 @@ fn ambient_drag_over_rendering_placeholder_copies_nothing() { ctrl.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 45, 1)); assert!( - ctrl.view_state().content_selection.is_none(), + ctrl.view_state().active.selection.is_none(), "no selection is seeded over the render placeholder" ); assert!( @@ -1682,7 +1687,7 @@ fn wrapped_ambient_plain_click_copies_nothing() { "a plain click under wrap copies nothing" ); assert!( - ctrl.view_state().content_selection.is_none(), + ctrl.view_state().active.selection.is_none(), "no selection highlight is left standing by a plain click" ); } diff --git a/tests/pinned_preview.rs b/tests/pinned_preview.rs new file mode 100644 index 00000000..7b485375 --- /dev/null +++ b/tests/pinned_preview.rs @@ -0,0 +1,1967 @@ +//! Pinned-preview interaction-state seams that are useful before pin lifecycle wiring lands. + +mod common; + +use common::TempDir; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use herdr_file_viewer::controller::{ + Components, ContentProvider, Controller, EditorHandoff, EditorOutcome, GitService, + RenderResult, RootProviders, +}; +use herdr_file_viewer::git::{Baseline, Status}; +use herdr_file_viewer::infile::SearchState; +use herdr_file_viewer::intent::Intent; +use herdr_file_viewer::opener::{Opener, OpenerOutcome}; +use herdr_file_viewer::presenter::{ + Focus, PaneGeometry, PreviewProjection, PreviewViewports, draw, +}; +use herdr_file_viewer::preview::PreviewPresentation; +use herdr_file_viewer::search::Match; +use herdr_file_viewer::view_policy::ViewMode; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::{Terminal, backend::TestBackend}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; +use std::time::{Duration, Instant}; + +#[derive(Default)] +struct StubGit; + +impl GitService for StubGit { + fn status(&self) -> BTreeMap { + BTreeMap::new() + } + + fn changed_set(&self, _baseline: Baseline) -> BTreeMap { + BTreeMap::new() + } + + fn diff(&self, _path: &Path, _baseline: Baseline, _full_context: bool) -> String { + String::new() + } + + fn diff_directory(&self, _path: &Path, _baseline: Baseline) -> String { + String::new() + } +} + +struct ChangedGit { + changed: BTreeMap, +} + +impl GitService for ChangedGit { + fn status(&self) -> BTreeMap { + self.changed.clone() + } + + fn changed_set(&self, _baseline: Baseline) -> BTreeMap { + self.changed.clone() + } + + fn diff(&self, _path: &Path, _baseline: Baseline, _full_context: bool) -> String { + String::new() + } + + fn diff_directory(&self, _path: &Path, _baseline: Baseline) -> String { + String::new() + } +} + +#[derive(Clone)] +struct Lines; + +impl ContentProvider for Lines { + fn render(&self, _path: &Path, _mode: ViewMode, _raw_diff: Option<&str>) -> RenderResult { + RenderResult { + content: Text::raw( + (1..=20) + .map(|n| format!("line {n} needle")) + .collect::>() + .join("\n"), + ), + notices: Vec::new(), + source: None, + } + } +} + +struct CountingLines { + calls: Arc, +} + +impl ContentProvider for CountingLines { + fn render(&self, _path: &Path, _mode: ViewMode, _raw_diff: Option<&str>) -> RenderResult { + self.calls.fetch_add(1, Ordering::SeqCst); + Lines.render(_path, _mode, _raw_diff) + } +} + +struct DiskContent; + +impl ContentProvider for DiskContent { + fn render(&self, path: &Path, _mode: ViewMode, _raw_diff: Option<&str>) -> RenderResult { + RenderResult { + content: Text::raw(std::fs::read_to_string(path).unwrap()), + notices: Vec::new(), + source: None, + } + } +} + +/// A rendered-Markdown fixture with presentation-specific styling and renderer notices. +/// Returning a distinct fallback for any other mode makes the test assert the policy selected +/// rendered Markdown before it freezes the display representation. +struct NoticedMarkdown; + +impl ContentProvider for NoticedMarkdown { + fn render(&self, _path: &Path, mode: ViewMode, _raw_diff: Option<&str>) -> RenderResult { + match mode { + ViewMode::RenderedMarkdown => RenderResult { + content: Text::from(Line::from(vec![ + Span::styled( + "rendered markdown", + Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::raw(" preview needle one needle two"), + ])), + notices: vec![ + "markdown renderer notice".into(), + "wrapped table truncated".into(), + ], + source: None, + }, + _ => RenderResult { + content: Text::raw("unexpected non-markdown presentation"), + notices: vec!["wrong presentation".into()], + source: None, + }, + } + } +} + +struct NoopEditor; + +impl EditorHandoff for NoopEditor { + fn open(&mut self, _file: &Path) -> EditorOutcome { + EditorOutcome::NoTakeover + } +} + +struct CountingEditor(Arc); + +impl EditorHandoff for CountingEditor { + fn open(&mut self, _file: &Path) -> EditorOutcome { + self.0.fetch_add(1, Ordering::SeqCst); + EditorOutcome::NoTakeover + } +} + +struct CountingOpener(Arc); + +impl Opener for CountingOpener { + fn open(&mut self, _path: &Path) -> OpenerOutcome { + self.0.fetch_add(1, Ordering::SeqCst); + OpenerOutcome::Launched + } + + fn reveal(&mut self, _path: &Path) -> OpenerOutcome { + self.0.fetch_add(1, Ordering::SeqCst); + OpenerOutcome::Launched + } +} + +fn controller(root: &Path) -> Controller { + let components = Components { + providers: Box::new(|_resolved| RootProviders { + git: Arc::new(StubGit), + content: Box::new(Lines), + }), + editor: Box::new(NoopEditor), + clipboard: Box::new(common::RecordingClipboard::default()), + renderers: None, + }; + Controller::new( + common::resolved(root.to_path_buf(), root.join(".git").is_dir()), + Baseline::Head, + components, + ) +} + +/// Build a controller whose clipboard log remains observable after construction. +fn controller_with_recording_clipboard(root: &Path) -> (Controller, Arc>>) { + let clipboard = common::RecordingClipboard::default(); + let copied = Arc::clone(&clipboard.copied); + let components = Components { + providers: Box::new(|_resolved| RootProviders { + git: Arc::new(StubGit), + content: Box::new(Lines), + }), + editor: Box::new(NoopEditor), + clipboard: Box::new(clipboard), + renderers: None, + }; + ( + Controller::new( + common::resolved(root.to_path_buf(), false), + Baseline::Head, + components, + ), + copied, + ) +} + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn await_content(ctrl: &mut Controller) { + let deadline = Instant::now() + Duration::from_secs(5); + while ctrl.content().lines.len() != 20 { + ctrl.poll(); + assert!(Instant::now() < deadline, "preview content never rendered"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn content_text(ctrl: &Controller) -> String { + ctrl.content() + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") +} + +fn await_content_containing(ctrl: &mut Controller, expected: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + while !content_text(ctrl).contains(expected) { + ctrl.poll(); + assert!( + Instant::now() < deadline, + "preview content never rendered {expected:?}" + ); + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn await_active_relative_path(ctrl: &mut Controller, expected: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + while ctrl + .active_document() + .is_none_or(|document| document.origin().root_relative_path() != Path::new(expected)) + { + ctrl.poll(); + assert!( + Instant::now() < deadline, + "active preview never rendered {expected:?}" + ); + std::thread::sleep(Duration::from_millis(5)); + } +} + +/// Compare every pinned field that represents the frozen display and independent interaction. +/// `PreviewProjection` intentionally has no blanket equality because its active-only overlays do +/// not, so keep the pin oracle explicit rather than silently comparing a subset. +fn assert_pinned_projection_eq(actual: &PreviewProjection, expected: &PreviewProjection) { + assert_eq!(actual.content, expected.content, "displayed styled lines"); + assert_eq!(actual.notices, expected.notices, "content-specific notices"); + assert_eq!(actual.title, expected.title, "captured title"); + assert_eq!(actual.rendering, expected.rendering, "rendering state"); + assert_eq!(actual.scroll, expected.scroll, "vertical scroll"); + assert_eq!(actual.hscroll, expected.hscroll, "horizontal scroll"); + assert_eq!(actual.rows, expected.rows, "display-row extent"); + assert_eq!(actual.wrap, expected.wrap, "wrapping presentation"); + assert_eq!(actual.pad_left, expected.pad_left, "presentation inset"); + assert_eq!(actual.origin, expected.origin, "captured origin"); + assert_eq!( + actual.flash.is_none(), + expected.flash.is_none(), + "flash presence" + ); + assert_eq!( + actual.line_select.is_none(), + expected.line_select.is_none(), + "line selection presence" + ); + assert_eq!( + actual.selection.is_none(), + expected.selection.is_none(), + "character selection presence" + ); + match (&actual.search, &expected.search) { + (Some(actual), Some(expected)) => { + assert_eq!(actual.matches, expected.matches, "search matches"); + assert_eq!(actual.current, expected.current, "current search match"); + } + (None, None) => {} + _ => panic!("search presence changed"), + } +} + +/// Assert no render was dispatched, deterministically. +/// +/// The provider counter alone cannot prove this: `dispatch_render` runs the provider on a spawned +/// thread, so reading the counter immediately can race a real dispatch and pass, and sleeping to +/// wait for it makes the negative wall-clock-dependent (this file was previously bitten by exactly +/// that). `Controller::render_seq` is bumped SYNCHRONOUSLY inside `dispatch_render` before the +/// worker is spawned, so an unchanged seq is a race-free proof; the counter then corroborates it. +fn assert_no_render_dispatched( + ctrl: &Controller, + calls: &AtomicUsize, + expected_calls: usize, + seq_before: u64, +) { + assert_eq!( + ctrl.render_seq(), + seq_before, + "dispatch_render bumps the render seq synchronously: no render must have been dispatched" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + expected_calls, + "pinning is synchronous state copying and must not dispatch content rendering" + ); +} + +fn draw_viewports(ctrl: &Controller, width: u16, height: u16) -> PreviewViewports { + let state = ctrl.view_state(); + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("test terminal"); + let mut viewports = PreviewViewports::default(); + terminal + .draw(|frame| viewports = draw(frame, &state)) + .expect("draw test frame"); + viewports +} + +fn pin_ready_controller() -> (TempDir, Controller) { + let dir = TempDir::new(); + std::fs::write(dir.path().join("preview.rs"), "placeholder\n").unwrap(); + let mut ctrl = controller(dir.path()); + await_content(&mut ctrl); + ctrl.set_preview_viewports(herdr_file_viewer::presenter::PreviewViewports { + active: (8, 4), + pinned: None, + }); + ctrl.handle(Intent::PinPreview); + ctrl.set_preview_viewports(herdr_file_viewer::presenter::PreviewViewports { + active: (8, 4), + pinned: Some((8, 4)), + }); + (dir, ctrl) +} + +#[test] +fn pin_focus_cycles_and_removal_returns_focus_to_active() { + let (_dir, mut ctrl) = pin_ready_controller(); + + assert_eq!(ctrl.focus(), Focus::Tree); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Tree); + + ctrl.handle(Intent::ToggleFocus); + ctrl.handle(Intent::PinPreview); + assert!(ctrl.view_state().pinned.is_none()); + assert_eq!(ctrl.focus(), Focus::Content); +} + +#[test] +fn pinned_search_navigation_moves_only_the_pinned_search_and_viewport_with_wrap_notices() { + let (_dir, mut ctrl) = pin_ready_controller(); + { + let active = ctrl.active_interaction_mut(); + active.vertical_scroll = 6; + active.search = Some(SearchState { + query: "active search stays put".into(), + matches: vec![Match { + line: 12, + start: 0, + end: 6, + }], + current: 0, + }); + } + // Re-pin after seeding active state so the pin's own prompt has a separate starting point. + ctrl.handle(Intent::PinPreview); + ctrl.handle(Intent::PinPreview); + ctrl.set_preview_viewports(PreviewViewports { + active: (8, 4), + pinned: Some((8, 4)), + }); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + let active_before = ctrl.active_interaction().clone(); + + ctrl.handle(Intent::OpenSearch); + for ch in "needle".chars() { + ctrl.handle_prompt_key(key(KeyCode::Char(ch))); + } + ctrl.handle_prompt_key(key(KeyCode::Enter)); + + ctrl.handle(Intent::NextMatch); + let pinned = ctrl.view_state().pinned.expect("pin remains present"); + assert_eq!(pinned.search.as_ref().map(|search| search.current), Some(1)); + assert_eq!(pinned.scroll, 1, "next match scrolls the pinned viewport"); + assert_eq!(ctrl.active_interaction(), &active_before); + + ctrl.handle(Intent::PrevMatch); + let pinned = ctrl.view_state().pinned.expect("pin remains present"); + assert_eq!(pinned.search.as_ref().map(|search| search.current), Some(0)); + assert_eq!( + pinned.scroll, 0, + "previous match scrolls the pinned viewport back" + ); + assert_eq!(ctrl.active_interaction(), &active_before); + + ctrl.handle(Intent::PrevMatch); + let pinned = ctrl.view_state().pinned.expect("pin remains present"); + assert_eq!( + pinned.search.as_ref().map(|search| search.current), + Some(19) + ); + assert_eq!( + pinned.scroll, 16, + "wrapped previous match reaches pinned bottom" + ); + assert_eq!(ctrl.action_notice(), Some("Search: wrapped to last match")); + assert_eq!(ctrl.active_interaction(), &active_before); + + ctrl.handle(Intent::NextMatch); + let pinned = ctrl.view_state().pinned.expect("pin remains present"); + assert_eq!(pinned.search.as_ref().map(|search| search.current), Some(0)); + assert_eq!(pinned.scroll, 0, "wrapped next match returns to pinned top"); + assert_eq!(ctrl.action_notice(), Some("Search: wrapped to first match")); + assert_eq!(ctrl.active_interaction(), &active_before); +} + +#[test] +fn pinned_search_cancel_restores_its_saved_scroll_and_clears_the_search() { + let (_dir, mut ctrl) = pin_ready_controller(); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + for _ in 0..3 { + ctrl.handle(Intent::NavDown); + } + let saved_scroll = ctrl + .view_state() + .pinned + .expect("pin remains present") + .scroll; + assert_eq!(saved_scroll, 3, "precondition: pinned preview was scrolled"); + + ctrl.handle(Intent::OpenSearch); + for ch in "needle".chars() { + ctrl.handle_prompt_key(key(KeyCode::Char(ch))); + } + assert_eq!( + ctrl.view_state() + .pinned + .expect("pin remains present") + .scroll, + 0, + "incremental search moved the pinned viewport before cancellation" + ); + + ctrl.handle_prompt_key(key(KeyCode::Esc)); + let pinned = ctrl.view_state().pinned.expect("pin remains present"); + assert_eq!( + pinned.scroll, saved_scroll, + "Esc restores pinned prompt scroll" + ); + assert!( + pinned.search.is_none(), + "Esc clears pinned search highlights" + ); +} + +#[test] +fn close_from_pinned_focus_dismisses_the_visible_active_search_before_quitting() { + let (_dir, mut ctrl) = pin_ready_controller(); + ctrl.handle(Intent::OpenSearch); + for ch in "place".chars() { + ctrl.handle_prompt_key(key(KeyCode::Char(ch))); + } + ctrl.handle_prompt_key(key(KeyCode::Enter)); + assert!( + ctrl.view_state().active.search.is_some(), + "precondition: a committed active search is highlighted" + ); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + + let fx = ctrl.handle(Intent::Close); + assert!( + !fx.quit, + "the first close dismisses the still-visible active search instead of quitting" + ); + assert!( + ctrl.view_state().active.search.is_none(), + "the visible active search was dismissed from pinned focus" + ); + assert_eq!(ctrl.focus(), Focus::Pinned, "dismissal does not move focus"); + assert!( + ctrl.view_state().pinned.is_some(), + "dismissal does not unpin" + ); + + let fx = ctrl.handle(Intent::Close); + assert!(fx.quit, "with no search left, close quits"); +} + +#[test] +fn close_from_pinned_focus_quits_past_a_hidden_active_search() { + let (_dir, mut ctrl) = pin_ready_controller(); + ctrl.handle(Intent::OpenSearch); + for ch in "place".chars() { + ctrl.handle_prompt_key(key(KeyCode::Char(ch))); + } + ctrl.handle_prompt_key(key(KeyCode::Enter)); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + // This fixture explicitly hides the active viewport, so close must not burn a keypress on its + // off-screen search even though the pin remains visible. + ctrl.set_preview_viewports(PreviewViewports { + active: (0, 0), + pinned: Some((8, 4)), + }); + + let fx = ctrl.handle(Intent::Close); + assert!(fx.quit, "no visible search stands between close and quit"); + assert!( + ctrl.active_interaction().search.is_some(), + "the hidden active search was not consumed on the way out" + ); +} + +#[test] +fn close_from_active_focus_dismisses_the_visible_pinned_search_before_quitting() { + let (_dir, mut ctrl) = pin_ready_controller(); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + ctrl.handle(Intent::OpenSearch); + for ch in "place".chars() { + ctrl.handle_prompt_key(key(KeyCode::Char(ch))); + } + ctrl.handle_prompt_key(key(KeyCode::Enter)); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Tree); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + + let fx = ctrl.handle(Intent::Close); + assert!( + !fx.quit, + "the first close dismisses the still-visible pinned search instead of quitting" + ); + assert!( + ctrl.view_state() + .pinned + .expect("pin remains present") + .search + .is_none(), + "the visible pinned search was dismissed from active focus" + ); + + let fx = ctrl.handle(Intent::Close); + assert!(fx.quit, "with no search left, close quits"); +} + +#[test] +fn pinned_scroll_search_and_paging_do_not_touch_active_interaction() { + let (_dir, mut ctrl) = pin_ready_controller(); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + + ctrl.handle(Intent::NavDown); + assert_eq!(ctrl.active_interaction().vertical_scroll, 0); + assert_eq!(ctrl.view_state().pinned.as_ref().unwrap().scroll, 1); + ctrl.handle(Intent::PageDown); + assert_eq!(ctrl.view_state().pinned.as_ref().unwrap().scroll, 5); + ctrl.handle(Intent::Expand); + assert_eq!(ctrl.active_interaction().horizontal_scroll, 0); + assert!(ctrl.view_state().pinned.as_ref().unwrap().hscroll > 0); + + ctrl.handle(Intent::OpenSearch); + for ch in "needle".chars() { + ctrl.handle_prompt_key(key(KeyCode::Char(ch))); + } + ctrl.handle_prompt_key(key(KeyCode::Enter)); + assert!(ctrl.active_interaction().search.is_none()); + assert_eq!( + ctrl.view_state() + .pinned + .as_ref() + .and_then(|p| p.search.as_ref()) + .map(|s| s.matches.len()), + Some(20) + ); +} + +#[test] +fn pinned_unavailable_actions_are_consumed_with_a_notice() { + let dir = TempDir::new(); + std::fs::write(dir.path().join("preview.rs"), "placeholder\n").unwrap(); + let editor_calls = Arc::new(AtomicUsize::new(0)); + let opener_calls = Arc::new(AtomicUsize::new(0)); + let clipboard = common::RecordingClipboard::default(); + let copied = Arc::clone(&clipboard.copied); + let editor_counter = Arc::clone(&editor_calls); + let components = Components { + providers: Box::new(|_resolved| RootProviders { + git: Arc::new(StubGit), + content: Box::new(Lines), + }), + editor: Box::new(CountingEditor(editor_counter)), + clipboard: Box::new(clipboard), + renderers: None, + }; + let mut ctrl = Controller::new( + common::resolved(dir.path().to_path_buf(), false), + Baseline::Head, + components, + ); + await_content(&mut ctrl); + ctrl.set_preview_viewports(herdr_file_viewer::presenter::PreviewViewports { + active: (8, 4), + pinned: None, + }); + ctrl.handle(Intent::PinPreview); + ctrl.set_preview_viewports(herdr_file_viewer::presenter::PreviewViewports { + active: (8, 4), + pinned: Some((8, 4)), + }); + ctrl.set_opener(Box::new(CountingOpener(Arc::clone(&opener_calls)))); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + let unavailable = [ + Intent::Activate, + Intent::OpenFullscreen, + Intent::OpenGoToLine, + Intent::TreeScrollRight, + Intent::OpenInEditor, + Intent::OpenWithApp, + Intent::RevealInFileManager, + Intent::AddAnnotation, + Intent::ShowAnnotations, + Intent::CycleDiffRender, + Intent::CycleView, + Intent::ToggleWrap, + ]; + for intent in unavailable { + let before = ctrl.view_state(); + let fx = ctrl.handle(intent); + assert!(fx.redraw, "{intent:?} reports rejection"); + assert_eq!(ctrl.focus(), Focus::Pinned, "{intent:?} keeps pinned focus"); + assert!( + ctrl.action_notice().is_some(), + "{intent:?} explains rejection" + ); + assert_eq!( + ctrl.view_state().pinned.as_ref().unwrap().scroll, + before.pinned.as_ref().unwrap().scroll + ); + assert_eq!( + ctrl.active_interaction().vertical_scroll, + before.active.scroll + ); + assert_eq!(editor_calls.load(Ordering::SeqCst), 0, "{intent:?}"); + assert_eq!(opener_calls.load(Ordering::SeqCst), 0, "{intent:?}"); + assert!(copied.lock().unwrap().is_empty(), "{intent:?}"); + } +} + +#[test] +fn pinned_incremental_search_borrows_its_document_and_preserves_matches() { + let (_dir, mut ctrl) = pin_ready_controller(); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + ctrl.handle(Intent::OpenSearch); + + for (typed, matches, first) in [ + ( + 'n', + 40, + Match { + line: 0, + start: 2, + end: 3, + }, + ), + ( + 'e', + 40, + Match { + line: 0, + start: 2, + end: 4, + }, + ), + ( + 'e', + 20, + Match { + line: 0, + start: 7, + end: 10, + }, + ), + ( + 'd', + 20, + Match { + line: 0, + start: 7, + end: 11, + }, + ), + ] { + ctrl.handle_prompt_key(key(KeyCode::Char(typed))); + let search = ctrl + .view_state() + .pinned + .and_then(|pin| pin.search) + .expect("each incremental query keeps pinned search state"); + assert_eq!( + search.current, 0, + "a changed query starts at its first match" + ); + assert_eq!(search.matches.len(), matches); + assert_eq!(search.matches[0], first); + } + + ctrl.handle_prompt_key(key(KeyCode::Enter)); + ctrl.handle(Intent::NextMatch); + let committed = ctrl + .view_state() + .pinned + .and_then(|pin| pin.search) + .expect("the committed pinned search remains available"); + assert_eq!(committed.current, 1); + assert_eq!(committed.matches[1].line, 1); + + assert!( + !include_str!("../src/controller/infile.rs").contains("document.content().lines.clone()"), + "incremental pinned search must borrow the frozen document lines rather than clone them" + ); +} + +#[test] +fn hiding_a_pin_keeps_no_pin_geometry_and_skips_pinned_focus() { + let (_dir, mut ctrl) = pin_ready_controller(); + let no_pin = { + ctrl.handle(Intent::PinPreview); + let viewports = draw_viewports(&ctrl, 60, 12); + ctrl.handle(Intent::PinPreview); + viewports + }; + let narrow = draw_viewports(&ctrl, 60, 12); + assert_eq!(narrow.pinned, None, "the narrow layout hides the pin only"); + assert_eq!( + narrow.active, no_pin.active, + "AC-16: pinning never changes active geometry" + ); + + ctrl.set_preview_viewports(narrow); + assert_eq!(ctrl.focus(), Focus::Tree); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Tree, "the hidden pin is never focused"); +} + +#[test] +fn pinned_y_copies_the_captured_root_relative_path_after_re_root() { + let original_root = TempDir::new(); + let original_file = original_root.path().join("pinned.rs"); + std::fs::write(&original_file, "original\n").unwrap(); + let new_root = TempDir::new(); + std::fs::write(new_root.path().join("current.rs"), "current\n").unwrap(); + let (mut ctrl, copied) = controller_with_recording_clipboard(original_root.path()); + + await_content(&mut ctrl); + ctrl.handle(Intent::PinPreview); + ctrl.re_root(new_root.path()); + await_content(&mut ctrl); + ctrl.set_preview_viewports(PreviewViewports { + active: (8, 4), + pinned: Some((8, 4)), + }); + std::fs::remove_dir_all(original_root.path()).unwrap(); + + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + ctrl.handle(Intent::CopyRepoPath); + + assert_eq!( + copied.lock().unwrap().as_slice(), + ["pinned.rs"], + "pinned y uses its captured origin, not the current tree or filesystem" + ); +} + +// Windows forbids control bytes in filesystem entry names. The controller's sanitizer is +// platform-independent, but this end-to-end proof needs a hostile path that can be pinned. +#[cfg(unix)] +#[test] +fn pinned_capital_y_copies_a_sanitized_captured_absolute_path_after_root_removal() { + let original_root = TempDir::new(); + let hostile_name = "pin\u{1b}[2J\u{7}\n.rs"; + let original_file = original_root.path().join(hostile_name); + std::fs::write(&original_file, "original\n").unwrap(); + let expected = original_file + .to_string_lossy() + .chars() + .filter(|ch| !ch.is_control()) + .collect::(); + let new_root = TempDir::new(); + std::fs::write(new_root.path().join("current.rs"), "current\n").unwrap(); + let (mut ctrl, copied) = controller_with_recording_clipboard(original_root.path()); + + await_content(&mut ctrl); + ctrl.handle(Intent::PinPreview); + ctrl.re_root(new_root.path()); + await_content(&mut ctrl); + ctrl.set_preview_viewports(PreviewViewports { + active: (8, 4), + pinned: Some((8, 4)), + }); + std::fs::remove_dir_all(original_root.path()).unwrap(); + + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + ctrl.handle(Intent::CopyAbsPath); + + let copied = copied.lock().unwrap(); + assert_eq!( + copied.as_slice(), + [expected], + "pinned Y uses the captured absolute origin without reading its removed root" + ); + assert!( + copied[0].chars().all(|ch| !ch.is_control()), + "clipboard text never carries hostile terminal or paste control characters" + ); +} + +#[test] +fn active_zoom_keeps_pin_while_pinned_fullscreen_is_rejected() { + let (_dir, mut ctrl) = pin_ready_controller(); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + ctrl.handle(Intent::OpenFullscreen); + assert!(!ctrl.zoomed()); + assert!(ctrl.view_state().pinned.is_some()); + assert!(ctrl.action_notice().is_some()); + + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Tree); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::OpenFullscreen); + assert!(ctrl.zoomed()); + assert!(ctrl.view_state().pinned.is_some()); +} + +#[test] +fn tree_hidden_focus_cycles_between_pinned_and_active() { + let (_dir, mut ctrl) = pin_ready_controller(); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + ctrl.handle(Intent::ToggleZoom); + assert!(ctrl.zoomed()); + assert_eq!(ctrl.focus(), Focus::Pinned); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); +} + +#[test] +fn active_interaction_can_be_copied_and_mutated_independently() { + let dir = TempDir::new(); + std::fs::write(dir.path().join("preview.rs"), "placeholder\n").unwrap(); + let mut ctrl = controller(dir.path()); + + let interaction = ctrl.active_interaction_mut(); + interaction.vertical_scroll = 7; + interaction.horizontal_scroll = 3; + interaction.search = Some(SearchState { + query: "needle".into(), + matches: vec![ + Match { + line: 2, + start: 7, + end: 13, + }, + Match { + line: 4, + start: 7, + end: 13, + }, + ], + current: 1, + }); + + let mut copied = ctrl.active_interaction().clone(); + copied.vertical_scroll = 11; + copied.horizontal_scroll = 9; + copied.search.as_mut().unwrap().query = "changed".into(); + + assert_eq!(ctrl.active_interaction().vertical_scroll, 7); + assert_eq!(ctrl.active_interaction().horizontal_scroll, 3); + assert_eq!( + ctrl.active_interaction().search.as_ref().unwrap().query, + "needle" + ); + assert_eq!( + ctrl.active_interaction().search.as_ref().unwrap().current, + 1 + ); + assert_ne!(copied, *ctrl.active_interaction()); +} + +#[test] +fn unpinned_navigation_projects_the_existing_active_interaction() { + let dir = TempDir::new(); + std::fs::write(dir.path().join("preview.rs"), "placeholder\n").unwrap(); + let mut ctrl = controller(dir.path()); + await_content(&mut ctrl); + ctrl.set_content_viewport(40, 5); + ctrl.set_pane_geometry(PaneGeometry { + content_inner: Some(Rect::new(0, 0, 40, 5)), + ..Default::default() + }); + + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::NavDown); + ctrl.handle(Intent::OpenSearch); + for ch in "needle".chars() { + ctrl.handle_prompt_key(key(KeyCode::Char(ch))); + } + ctrl.handle_prompt_key(key(KeyCode::Enter)); + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 1, + row: 0, + modifiers: KeyModifiers::NONE, + }); + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 6, + row: 0, + modifiers: KeyModifiers::NONE, + }); + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 6, + row: 0, + modifiers: KeyModifiers::NONE, + }); + + assert_eq!(ctrl.active_interaction().vertical_scroll, 0); + assert_eq!(ctrl.view_state().active.scroll, 0); + assert_eq!(ctrl.active_interaction().horizontal_scroll, 0); + assert_eq!( + ctrl.search().map(|search| search.query.as_str()), + Some("needle") + ); + assert_eq!( + ctrl.view_state() + .active + .search + .as_ref() + .map(|search| search.matches.len()), + Some(20) + ); + assert!(ctrl.active_interaction().selection.is_some()); + assert!(ctrl.view_state().active.selection.is_some()); +} + +#[test] +fn layout_only_wrap_toggle_keeps_the_active_document_presentation_in_sync() { + let dir = TempDir::new(); + std::fs::write(dir.path().join("preview.rs"), "placeholder\n").unwrap(); + let mut ctrl = controller(dir.path()); + await_content(&mut ctrl); + + ctrl.handle(Intent::ToggleWrap); + + let projected = ctrl.view_state(); + let settled = *ctrl + .active_document() + .expect("the syntax preview remains a settled active document") + .presentation(); + assert_eq!( + settled, + PreviewPresentation::new( + settled.view_mode(), + projected.active.wrap, + projected.active.pad_left, + ) + ); +} + +#[test] +fn no_pin_navigation_does_not_add_content_provider_work() { + let dir = TempDir::new(); + std::fs::write(dir.path().join("preview.rs"), "placeholder\n").unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let provider_calls = Arc::clone(&calls); + let components = Components { + providers: Box::new(move |_resolved| RootProviders { + git: Arc::new(StubGit), + content: Box::new(CountingLines { + calls: Arc::clone(&provider_calls), + }), + }), + editor: Box::new(NoopEditor), + clipboard: Box::new(common::RecordingClipboard::default()), + renderers: None, + }; + let mut ctrl = Controller::new( + common::resolved(dir.path().to_path_buf(), false), + Baseline::Head, + components, + ); + await_content(&mut ctrl); + assert_eq!(calls.load(Ordering::SeqCst), 1, "one initial file render"); + + ctrl.set_content_viewport(40, 5); + ctrl.set_pane_geometry(PaneGeometry { + content_inner: Some(Rect::new(0, 0, 40, 5)), + ..Default::default() + }); + let seq_before = ctrl.render_seq(); + ctrl.handle(Intent::ToggleFocus); + ctrl.handle(Intent::NavDown); + ctrl.handle(Intent::OpenSearch); + for ch in "needle".chars() { + ctrl.handle_prompt_key(key(KeyCode::Char(ch))); + } + ctrl.handle_prompt_key(key(KeyCode::Enter)); + ctrl.handle(Intent::NextMatch); + ctrl.handle(Intent::PrevMatch); + + assert_no_render_dispatched(&ctrl, &calls, 1, seq_before); +} + +#[test] +fn loading_pin_attempt_is_rejected_with_the_rendering_notice_and_no_snapshot() { + let loading_root = TempDir::new(); + std::fs::write(loading_root.path().join("preview.rs"), "placeholder\n").unwrap(); + let mut loading = controller(loading_root.path()); + + assert!( + loading.active_document().is_none(), + "loading is not pinnable" + ); + assert!(loading.pin_active_preview().redraw); + assert_eq!( + loading.action_notice(), + Some("Cannot pin while preview is rendering") + ); + assert!(loading.view_state().pinned.is_none()); +} + +#[test] +fn loading_directory_and_empty_tree_are_not_active_documents() { + let loading_root = TempDir::new(); + std::fs::write(loading_root.path().join("preview.rs"), "placeholder\n").unwrap(); + let loading = controller(loading_root.path()); + assert!( + loading.active_document().is_none(), + "loading is not pinnable" + ); + + let directory_root = TempDir::new(); + std::fs::create_dir(directory_root.path().join("child")).unwrap(); + let directory = controller(directory_root.path()); + assert!( + directory.active_document().is_none(), + "directory guidance is not pinnable" + ); + + let empty_root = TempDir::new(); + let empty = controller(empty_root.path()); + assert!( + empty.active_document().is_none(), + "empty-tree guidance is not pinnable" + ); +} + +#[test] +fn pin_lifecycle_clones_the_settled_preview_and_toggles_the_same_identity() { + let dir = TempDir::new(); + std::fs::write(dir.path().join("preview.md"), "placeholder\n").unwrap(); + let components = Components { + providers: Box::new(|_resolved| RootProviders { + git: Arc::new(StubGit), + content: Box::new(NoticedMarkdown), + }), + editor: Box::new(NoopEditor), + clipboard: Box::new(common::RecordingClipboard::default()), + renderers: None, + }; + let mut ctrl = Controller::new( + common::resolved(dir.path().to_path_buf(), false), + Baseline::Head, + components, + ); + await_content_containing(&mut ctrl, "rendered markdown preview"); + assert_eq!( + ctrl.active_document() + .expect("settled Markdown document") + .presentation() + .view_mode(), + ViewMode::RenderedMarkdown, + "the pin captures a rendered-Markdown presentation, not just syntax content" + ); + let active_content = ctrl.content().clone(); + let active_notices = ctrl.view_state().active.notices; + let interaction = ctrl.active_interaction_mut(); + interaction.vertical_scroll = 7; + interaction.horizontal_scroll = 3; + interaction.search = Some(SearchState { + query: "needle".into(), + matches: vec![ + Match { + line: 0, + start: 26, + end: 32, + }, + Match { + line: 0, + start: 37, + end: 43, + }, + ], + current: 1, + }); + + assert!(ctrl.pin_active_preview().redraw); + let pin = ctrl.view_state().pinned.expect("settled file is pinned"); + assert_eq!( + *pin.content, active_content, + "styled displayed lines are frozen" + ); + assert_eq!( + pin.notices, active_notices, + "content-specific renderer notices are frozen with the presentation" + ); + assert_eq!(pin.scroll, 7); + assert_eq!(pin.hscroll, 3); + assert_eq!( + pin.search.as_ref().map(|search| search.matches.len()), + Some(2) + ); + assert_eq!( + pin.search.as_ref().map(|search| search.current), + Some(1), + "the captured search keeps its non-zero current match" + ); + assert_eq!(ctrl.view_state().preview_split_pct, 50); + ctrl.set_preview_viewports(PreviewViewports { + active: (8, 4), + pinned: Some((8, 4)), + }); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + assert!( + ctrl.view_state() + .prompt + .as_deref() + .is_some_and(|status| status.contains("Search: needle (2/2)")), + "the captured search keeps its query as well as its current match" + ); + + assert!(ctrl.pin_active_preview().redraw); + assert!( + ctrl.view_state().pinned.is_none(), + "same identity removes the pin" + ); + + assert!(ctrl.pin_active_preview().redraw); + assert!(ctrl.view_state().pinned.is_some()); + assert_eq!(ctrl.view_state().preview_split_pct, 50); +} + +#[test] +fn pin_preview_intent_invokes_the_existing_snapshot_lifecycle() { + // T-13 only wires the registry/dispatcher to T-6's already-tested lifecycle; it must not + // create a second pin implementation. + let dir = TempDir::new(); + std::fs::write(dir.path().join("preview.rs"), "placeholder\n").unwrap(); + let mut ctrl = controller(dir.path()); + await_content(&mut ctrl); + + assert!(ctrl.handle(Intent::PinPreview).redraw); + assert!( + ctrl.view_state().pinned.is_some(), + "dispatch must create T-6's snapshot" + ); +} + +#[test] +fn preview_resize_intents_move_only_the_pinned_share_and_preserve_it_across_repinning() { + let (_dir, mut ctrl) = pin_ready_controller(); + let tree_split = ctrl.split_pct(); + + ctrl.handle(Intent::ShrinkPreview); + assert_eq!(ctrl.view_state().preview_split_pct, 45); + assert_eq!( + ctrl.split_pct(), + tree_split, + "preview resize must not move the tree divider" + ); + + for _ in 0..10 { + ctrl.handle(Intent::ShrinkPreview); + } + assert_eq!( + ctrl.view_state().preview_split_pct, + 20, + "shrink clamps at 20%" + ); + + for _ in 0..20 { + ctrl.handle(Intent::GrowPreview); + } + assert_eq!( + ctrl.view_state().preview_split_pct, + 80, + "grow clamps at 80%" + ); + assert_eq!( + ctrl.split_pct(), + tree_split, + "preview resize must not move the tree divider" + ); + + ctrl.handle(Intent::PinPreview); + assert!(ctrl.view_state().pinned.is_none()); + ctrl.handle(Intent::PinPreview); + assert!(ctrl.view_state().pinned.is_some()); + assert_eq!( + ctrl.view_state().preview_split_pct, + 80, + "repinning preserves the ratio" + ); + assert_eq!( + ctrl.split_pct(), + tree_split, + "repinning must not move the tree divider" + ); +} + +#[test] +fn preview_resize_intents_are_inert_when_the_pin_is_not_drawn() { + let (_dir, mut ctrl) = pin_ready_controller(); + ctrl.set_preview_viewports(PreviewViewports { + active: (8, 4), + pinned: None, + }); + let ratio = ctrl.view_state().preview_split_pct; + assert!(!ctrl.handle(Intent::ShrinkPreview).redraw); + assert!(!ctrl.handle(Intent::GrowPreview).redraw); + assert_eq!(ctrl.view_state().preview_split_pct, ratio); +} + +#[test] +fn preview_resize_intents_are_inert_without_a_pin() { + let dir = TempDir::new(); + std::fs::write(dir.path().join("preview.rs"), "placeholder\n").unwrap(); + let mut ctrl = controller(dir.path()); + let ratio = ctrl.view_state().preview_split_pct; + let tree_split = ctrl.split_pct(); + + assert!(!ctrl.handle(Intent::ShrinkPreview).redraw); + assert!(!ctrl.handle(Intent::GrowPreview).redraw); + assert_eq!(ctrl.view_state().preview_split_pct, ratio); + assert_eq!(ctrl.split_pct(), tree_split); +} + +#[test] +fn mouse_routes_pinned_scroll_and_preview_divider_drag_without_touching_active() { + let (_dir, mut ctrl) = pin_ready_controller(); + ctrl.set_pane_geometry(PaneGeometry { + // This is the measured wide split layout: the tree ends before the preview area, and + // the pinned/active divider is inside that area. Mouse routing must use these live + // measurements rather than infer either boundary from the configured percentages. + preview_area_x: 40, + preview_area_width: 50, + preview_divider_x: Some(65), + content_inner: Some(Rect::new(40, 1, 8, 4)), + pinned_inner: Some(Rect::new(65, 1, 8, 4)), + ..PaneGeometry::default() + }); + + let active_before = ctrl.active_interaction().clone(); + let pinned_before = ctrl.view_state().pinned.expect("pin is present"); + let tree_split_before = ctrl.split_pct(); + + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 67, + row: 2, + modifiers: KeyModifiers::NONE, + }); + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::ScrollRight, + column: 67, + row: 2, + modifiers: KeyModifiers::NONE, + }); + + let pinned_after_scroll = ctrl.view_state().pinned.expect("pin remains present"); + assert!(pinned_after_scroll.scroll > pinned_before.scroll); + assert!(pinned_after_scroll.hscroll > pinned_before.hscroll); + assert_eq!(ctrl.active_interaction(), &active_before); + + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 65, + row: 2, + modifiers: KeyModifiers::NONE, + }); + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 41, + row: 2, + modifiers: KeyModifiers::NONE, + }); + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 41, + row: 2, + modifiers: KeyModifiers::NONE, + }); + + assert_eq!( + ctrl.view_state().preview_split_pct, + 80, + "dragging toward the active edge grows the pinned share" + ); + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 65, + row: 2, + modifiers: KeyModifiers::NONE, + }); + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 89, + row: 2, + modifiers: KeyModifiers::NONE, + }); + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 89, + row: 2, + modifiers: KeyModifiers::NONE, + }); + assert_eq!(ctrl.view_state().preview_split_pct, 20); + assert_eq!(ctrl.split_pct(), tree_split_before); + assert_eq!(ctrl.active_interaction(), &active_before); +} + +#[test] +fn preview_divider_drag_maps_the_cursor_column_to_a_proportional_pinned_share() { + let (_dir, mut ctrl) = pin_ready_controller(); + // Measured geometry: active spans columns 40..65, the pin spans 65..90, divider at 65. + ctrl.set_pane_geometry(PaneGeometry { + preview_area_x: 40, + preview_area_width: 50, + preview_divider_x: Some(65), + content_inner: Some(Rect::new(40, 1, 8, 4)), + pinned_inner: Some(Rect::new(65, 1, 8, 4)), + ..PaneGeometry::default() + }); + + // The pin is on the right, so its proportional share is measured from the right edge. + // Interior columns must not merely snap toward the drag direction. + for (column, expected_pct) in [(51, 78), (55, 70), (65, 50), (72, 36), (79, 22)] { + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 65, + row: 2, + modifiers: KeyModifiers::NONE, + }); + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column, + row: 2, + modifiers: KeyModifiers::NONE, + }); + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column, + row: 2, + modifiers: KeyModifiers::NONE, + }); + assert_eq!( + ctrl.view_state().preview_split_pct, + expected_pct, + "dragging the divider to column {column} maps to a {expected_pct}% pinned share" + ); + } +} + +#[test] +fn pinned_scrollbar_press_and_drag_preserve_the_active_selection() { + let (_dir, mut ctrl) = pin_ready_controller(); + ctrl.set_pane_geometry(PaneGeometry { + content_inner: Some(Rect::new(60, 1, 8, 4)), + ..PaneGeometry::default() + }); + + // Establish a standing active selection first. A pinned snapshot has no selection/copy path, + // so input directed at its scrollbars must not dismiss this active-pane selection. + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 61, + row: 2, + modifiers: KeyModifiers::NONE, + }); + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 65, + row: 2, + modifiers: KeyModifiers::NONE, + }); + ctrl.handle_mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 65, + row: 2, + modifiers: KeyModifiers::NONE, + }); + let selection_before = ctrl + .active_interaction() + .selection + .expect("the active drag establishes a standing selection"); + + ctrl.set_pane_geometry(PaneGeometry { + pinned_vbar: Some(Rect::new(40, 1, 1, 4)), + pinned_hbar: Some(Rect::new(41, 5, 8, 1)), + ..PaneGeometry::default() + }); + + for event in [ + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 40, + row: 4, + modifiers: KeyModifiers::NONE, + }, + MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 40, + row: 1, + modifiers: KeyModifiers::NONE, + }, + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 40, + row: 1, + modifiers: KeyModifiers::NONE, + }, + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 48, + row: 5, + modifiers: KeyModifiers::NONE, + }, + MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 41, + row: 5, + modifiers: KeyModifiers::NONE, + }, + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 41, + row: 5, + modifiers: KeyModifiers::NONE, + }, + ] { + ctrl.handle_mouse(event); + } + + assert_eq!( + ctrl.active_interaction().selection.as_ref(), + Some(&selection_before) + ); +} + +#[test] +fn pinning_a_different_file_replaces_one_frozen_snapshot_without_rendering() { + let dir = TempDir::new(); + std::fs::write(dir.path().join("a.rs"), "a\n").unwrap(); + std::fs::write(dir.path().join("b.rs"), "b\n").unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let provider_calls = Arc::clone(&calls); + let components = Components { + providers: Box::new(move |_resolved| RootProviders { + git: Arc::new(StubGit), + content: Box::new(CountingLines { + calls: Arc::clone(&provider_calls), + }), + }), + editor: Box::new(NoopEditor), + clipboard: Box::new(common::RecordingClipboard::default()), + renderers: None, + }; + let mut ctrl = Controller::new( + common::resolved(dir.path().to_path_buf(), false), + Baseline::Head, + components, + ); + await_content(&mut ctrl); + assert_eq!(calls.load(Ordering::SeqCst), 1); + ctrl.pin_active_preview(); + let first = ctrl + .view_state() + .pinned + .expect("first file was captured") + .origin + .expect("pins retain origins"); + + ctrl.handle(Intent::NavDown); + await_content(&mut ctrl); + let frozen = ctrl.view_state().pinned.expect("navigation keeps the pin"); + assert_eq!(frozen.origin.as_ref(), Some(&first)); + + // Scroll the active preview off the top first: `dispatch_render` zeroes the active scroll + // synchronously, so a preserved offset is the race-free tell that replacing a pin dispatched + // no render (see `assert_no_render_dispatched`). + let calls_before_pin = calls.load(Ordering::SeqCst); + let seq_before_pin = ctrl.render_seq(); + ctrl.pin_active_preview(); + assert_no_render_dispatched(&ctrl, &calls, calls_before_pin, seq_before_pin); + let replacement = ctrl.view_state().pinned.expect("different file replaces"); + assert_ne!(replacement.origin.as_ref(), Some(&first)); + assert_eq!(replacement.content.lines.len(), 20); +} + +#[test] +fn rejected_pin_attempts_leave_no_file_and_directory_targets_explained() { + let empty_root = TempDir::new(); + let mut empty = controller(empty_root.path()); + assert!(empty.pin_active_preview().redraw); + assert!(empty.view_state().pinned.is_none()); + assert_eq!( + empty.action_notice(), + Some("Cannot pin: no file is selected") + ); + + let directory_root = TempDir::new(); + std::fs::create_dir(directory_root.path().join("child")).unwrap(); + let mut directory = controller(directory_root.path()); + assert!(directory.pin_active_preview().redraw); + assert!(directory.view_state().pinned.is_none()); + assert_eq!(directory.action_notice(), Some("Cannot pin a directory")); + + let existing_root = TempDir::new(); + std::fs::create_dir(existing_root.path().join("folder")).unwrap(); + std::fs::write(existing_root.path().join("preview.rs"), "placeholder\n").unwrap(); + let mut existing = controller(existing_root.path()); + // The directory sorts first. Pin the file, then return to the ineligible directory. + existing.handle(Intent::NavDown); + await_content(&mut existing); + { + let interaction = existing.active_interaction_mut(); + interaction.vertical_scroll = 7; + interaction.horizontal_scroll = 3; + interaction.search = Some(SearchState { + query: "needle".into(), + matches: vec![Match { + line: 4, + start: 7, + end: 13, + }], + current: 0, + }); + } + existing.pin_active_preview(); + let before = existing.view_state().pinned.expect("file pin precondition"); + existing.handle(Intent::NavUp); + assert!( + existing.active_document().is_none(), + "directory is ineligible" + ); + + existing.pin_active_preview(); + assert_eq!(existing.action_notice(), Some("Cannot pin a directory")); + let after = existing + .view_state() + .pinned + .expect("directory rejection keeps the existing pin"); + assert_pinned_projection_eq(&after, &before); + + // Empty the tree after the file was frozen. The next rejected pin is AC-4's no-selection + // path, which must retain the already-captured document rather than treating no selection as + // a request to clear it. + std::fs::remove_dir(existing_root.path().join("folder")).unwrap(); + std::fs::remove_file(existing_root.path().join("preview.rs")).unwrap(); + existing.handle(Intent::Refresh); + assert!(existing.tree().selected().is_none(), "no file is selected"); + assert!( + existing.active_document().is_none(), + "empty tree is ineligible" + ); + + existing.pin_active_preview(); + assert_eq!( + existing.action_notice(), + Some("Cannot pin: no file is selected") + ); + let after_no_selection = existing + .view_state() + .pinned + .expect("no-selection rejection keeps the existing pin"); + assert_pinned_projection_eq(&after_no_selection, &before); +} + +#[test] +fn changed_file_jumps_from_pinned_focus_retarget_only_the_active_preview() { + let dir = TempDir::new(); + std::fs::write(dir.path().join("a.rs"), "a\n").unwrap(); + std::fs::write(dir.path().join("b.rs"), "b\n").unwrap(); + let changed = BTreeMap::from([ + (PathBuf::from("a.rs"), Status::Modified), + (PathBuf::from("b.rs"), Status::Modified), + ]); + let components = Components { + providers: Box::new(move |_resolved| RootProviders { + git: Arc::new(ChangedGit { + changed: changed.clone(), + }), + content: Box::new(Lines), + }), + editor: Box::new(NoopEditor), + clipboard: Box::new(common::RecordingClipboard::default()), + renderers: None, + }; + let mut ctrl = Controller::new( + common::resolved(dir.path().to_path_buf(), true), + Baseline::Head, + components, + ); + await_active_relative_path(&mut ctrl, "a.rs"); + { + let active = ctrl.active_interaction_mut(); + active.vertical_scroll = 6; + active.horizontal_scroll = 3; + active.search = Some(SearchState { + query: "needle".into(), + matches: vec![Match { + line: 4, + start: 7, + end: 13, + }], + current: 0, + }); + } + ctrl.handle(Intent::PinPreview); + ctrl.set_preview_viewports(PreviewViewports { + active: (8, 4), + pinned: Some((8, 4)), + }); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + let pinned_before = ctrl.view_state().pinned.expect("pin precondition"); + let pinned_search_status = ctrl + .view_state() + .prompt + .expect("pinned search status exposes the captured query"); + let active_before = ctrl + .active_document() + .expect("active document precondition") + .origin() + .identity(); + + ctrl.handle(Intent::NextChanged); + await_active_relative_path(&mut ctrl, "b.rs"); + let active_after_next = ctrl + .active_document() + .expect("next changed document") + .origin() + .identity(); + assert_ne!( + active_after_next, active_before, + "next changed retargets active" + ); + assert_eq!(ctrl.focus(), Focus::Pinned, "pinned focus stays put"); + let pinned_after_next = ctrl.view_state().pinned.expect("pin remains present"); + assert_pinned_projection_eq(&pinned_after_next, &pinned_before); + assert_eq!(ctrl.view_state().prompt, Some(pinned_search_status.clone())); + + ctrl.handle(Intent::PrevChanged); + await_active_relative_path(&mut ctrl, "a.rs"); + assert_eq!( + ctrl.active_document() + .expect("previous changed document") + .origin() + .identity(), + active_before, + "previous changed retargets only the active preview back" + ); + assert_eq!(ctrl.focus(), Focus::Pinned, "pinned focus stays put"); + let pinned_after_prev = ctrl.view_state().pinned.expect("pin remains present"); + assert_pinned_projection_eq(&pinned_after_prev, &pinned_before); + assert_eq!(ctrl.view_state().prompt, Some(pinned_search_status)); +} + +#[test] +fn branch_changed_pin_replacement_names_named_and_detached_states() { + let dir = TempDir::new(); + std::fs::write(dir.path().join("preview.rs"), "placeholder\n").unwrap(); + assert!( + Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(dir.path()) + .status() + .expect("run git init") + .success(), + "git fixture initializes" + ); + assert!( + Command::new("git") + .args(["add", "preview.rs"]) + .current_dir(dir.path()) + .status() + .expect("stage fixture file") + .success() + ); + assert!( + Command::new("git") + .args([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-m", + "initial", + ]) + .current_dir(dir.path()) + .status() + .expect("commit fixture file") + .success() + ); + let mut ctrl = controller(dir.path()); + await_content(&mut ctrl); + ctrl.pin_active_preview(); + + assert!( + Command::new("git") + .args(["switch", "-c", "feature"]) + .current_dir(dir.path()) + .status() + .expect("switch fixture branch") + .success(), + "git fixture switches branch" + ); + ctrl.handle(Intent::Refresh); + await_content(&mut ctrl); + ctrl.pin_active_preview(); + assert_eq!( + ctrl.action_notice(), + Some("Replaced pin (branch changed: main → feature)"), + "AC-42 names both branch states when same-root/path replacement is caused by a branch change" + ); +} + +#[test] +fn finder_confirm_from_pinned_focus_moves_to_active_while_changed_jumps_do_not() { + let (_dir, mut ctrl) = pin_ready_controller(); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + + ctrl.handle(Intent::OpenFinder); + assert!(ctrl.finder_open(), "finder opens from pinned focus"); + ctrl.handle_finder_key(key(KeyCode::Char('p'))); + ctrl.handle_finder_key(key(KeyCode::Enter)); + assert_eq!( + ctrl.focus(), + Focus::Content, + "AC-41 finder confirmation takes focus to its file" + ); +} + +/// AC-41 moves focus from the PINNED pane only. A confirm from the tree keeps tree focus, which is +/// the pre-feature behaviour: widening the rule to every confirm would silently take `j`/`k` away +/// from the tree cursor, which no criterion asks for. +#[test] +fn finder_confirm_from_tree_focus_leaves_focus_on_the_tree() { + let (_dir, mut ctrl) = pin_ready_controller(); + assert_eq!(ctrl.focus(), Focus::Tree, "precondition: tree focus"); + + ctrl.handle(Intent::OpenFinder); + assert!(ctrl.finder_open(), "finder opens from tree focus"); + ctrl.handle_finder_key(key(KeyCode::Char('p'))); + ctrl.handle_finder_key(key(KeyCode::Enter)); + assert_eq!( + ctrl.focus(), + Focus::Tree, + "a confirm from the tree must not steal focus to the preview" + ); +} + +/// AC-12: a pin captured in the CURRENT worktree names no worktree, so the title stays short. The +/// comparison is over full root paths, not basenames, because two checkouts of one repo commonly +/// share a basename and a basename comparison would silently call a foreign pin local. +#[test] +fn a_pin_from_the_viewed_worktree_names_no_worktree() { + let (_dir, ctrl) = pin_ready_controller(); + assert!( + ctrl.view_state().pinned.is_some(), + "precondition: a pin exists" + ); + assert_eq!( + ctrl.view_state().pinned_foreign_root, + None, + "a same-worktree pin must not repeat the viewed worktree's name" + ); +} + +#[test] +fn active_scroll_and_search_leave_the_pinned_interaction_unchanged() { + let (_dir, mut ctrl) = pin_ready_controller(); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + ctrl.handle(Intent::NavDown); + ctrl.handle(Intent::Expand); + ctrl.handle(Intent::OpenSearch); + for ch in "needle".chars() { + ctrl.handle_prompt_key(key(KeyCode::Char(ch))); + } + ctrl.handle_prompt_key(key(KeyCode::Enter)); + let pinned_before = ctrl + .view_state() + .pinned + .expect("pinned interaction precondition"); + + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Tree); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::NavDown); + ctrl.handle(Intent::Expand); + ctrl.handle(Intent::OpenSearch); + for ch in "needle".chars() { + ctrl.handle_prompt_key(key(KeyCode::Char(ch))); + } + ctrl.handle_prompt_key(key(KeyCode::Enter)); + assert!( + ctrl.active_interaction().search.is_some(), + "precondition: active search committed" + ); + + let pinned_after = ctrl.view_state().pinned.expect("pin remains present"); + assert_eq!(pinned_after.scroll, pinned_before.scroll); + assert_eq!(pinned_after.hscroll, pinned_before.hscroll); + match (&pinned_after.search, &pinned_before.search) { + (Some(actual), Some(expected)) => { + assert_eq!(actual.matches, expected.matches); + assert_eq!(actual.current, expected.current); + } + _ => panic!("active interaction changed pinned search presence"), + } + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + assert!( + ctrl.view_state() + .prompt + .as_deref() + .is_some_and(|status| status.contains("Search: needle (1/20)")), + "active search changes cannot replace the pinned query" + ); +} + +#[test] +fn active_refresh_and_width_reflow_do_not_change_a_pinned_snapshot() { + let dir = TempDir::new(); + let path = dir.path().join("preview.md"); + std::fs::write(&path, "before reflow\n").unwrap(); + let components = Components { + providers: Box::new(|_resolved| RootProviders { + git: Arc::new(StubGit), + content: Box::new(DiskContent), + }), + editor: Box::new(NoopEditor), + clipboard: Box::new(common::RecordingClipboard::default()), + renderers: None, + }; + let mut ctrl = Controller::new( + common::resolved(dir.path().to_path_buf(), false), + Baseline::Head, + components, + ); + await_content_containing(&mut ctrl, "before reflow"); + ctrl.pin_active_preview(); + let before = ctrl.view_state().pinned.expect("pin before active changes"); + + std::fs::write(&path, "after reflow\n").unwrap(); + ctrl.set_content_viewport(40, 5); + await_content_containing(&mut ctrl, "after reflow"); + std::fs::write(&path, "after refresh\n").unwrap(); + ctrl.handle(Intent::Refresh); + await_content_containing(&mut ctrl, "after refresh"); + let after = ctrl + .view_state() + .pinned + .expect("pin survives active refresh"); + assert_eq!(after.content, before.content); + assert_eq!(after.notices, before.notices); + assert_eq!(after.origin, before.origin); + assert_eq!(after.scroll, before.scroll); + assert_eq!(after.hscroll, before.hscroll); +} diff --git a/tests/presenter.rs b/tests/presenter.rs index 92035132..15b5f451 100644 --- a/tests/presenter.rs +++ b/tests/presenter.rs @@ -6,8 +6,8 @@ use herdr_file_viewer::git::Status; use herdr_file_viewer::presenter::{ AnnotationEditorKind, AnnotationEditorView, AnnotationIndicatorsView, AnnotationOverviewView, AnnotationRowView, AnnotationTargetView, CharSelView, ContentSearch, DiscardConfirmView, - FinderView, FlashLine, Focus, HelpView, LineSelectView, PickerRowView, PickerView, ViewState, - draw, + FinderView, FlashLine, Focus, HelpView, LineSelectView, PickerRowView, PickerView, + PreviewProjection, ViewState, draw, }; use herdr_file_viewer::render::to_text; use herdr_file_viewer::search::Match; @@ -63,24 +63,24 @@ fn sample_state() -> ViewState { Some(Status::Untracked), ), ]; + let mut active = PreviewProjection::new( + "main.rs", + to_text("fn main() {\n println!(\"hello\");\n}\n"), + ); + active.notices = vec![ + "Showing first 5000 lines (truncated)".to_string(), + "delta not found — showing plain diff".to_string(), + ]; ViewState { nodes, selected: 1, // main.rs - content: to_text("fn main() {\n println!(\"hello\");\n}\n"), - notices: vec![ - "Showing first 5000 lines (truncated)".to_string(), // AC-13 - "delta not found — showing plain diff".to_string(), // AC-25 - ], - flash: None, + active, + pinned: None, focus: Focus::Tree, width: 100, - content_scroll: 0, - content_hscroll: 0, tree_scroll: 0, tree_hscroll: 0, - content_rows: 3, // the fixture content is three lines - wrap: false, - content_pad_left: false, + preview_split_pct: 50, split_pct: 40, tree_position: herdr_file_viewer::config::TreePosition::Left, // A high cap so the percentage governs in these fixtures (the cap only bites on very wide @@ -98,12 +98,8 @@ fn sample_state() -> ViewState { annotation_indicators: AnnotationIndicatorsView::default(), root_name: "r".to_string(), // the fixture tree is rooted at /r branch: None, + pinned_foreign_root: None, prompt: None, - content_title: Some("main.rs".to_string()), - content_rendering: false, - search: None, - line_select: None, - content_selection: None, help: None, } } @@ -238,7 +234,7 @@ fn dirty_directory_carries_a_non_color_glyph_marker() { // (LightRed) and lost to a colorblind user or a non-default theme. Files keep their M/A/D/? // letters; clean directories and clean files show a blank, so the column stays aligned. let mut state = sample_state(); - state.notices = vec![]; + state.active.notices = vec![]; // A dirty directory (dir_dirty = true) at the root, plus a clean directory for contrast. state.nodes = vec![ Node { @@ -286,7 +282,7 @@ fn dirty_directory_glyph_snapshot() { // snapshot the tree with a dirty directory so the `●` glyph is locked into the // recorded layout — a regression that drops the glyph (back to color-only) is caught here. let mut state = sample_state(); - state.notices = vec![]; + state.active.notices = vec![]; state.nodes = vec![ Node { path: PathBuf::from("/r/changed"), @@ -382,7 +378,7 @@ fn flash_line_renders_atop_the_notices_and_does_not_hide_them() { // The self-expiring flash gets its own row above the persistent notices, so the flash text // and both notices are all visible at once (the strip reserves a row for it). let mut state = sample_state(); - state.flash = Some(FlashLine { + state.active.flash = Some(FlashLine { text: "Diff: side-by-side".to_string(), dim: false, }); @@ -447,7 +443,7 @@ fn hostile_notice_emits_no_control_bytes_to_the_buffer() { // content title, and picker rows — no control byte may reach the terminal as drawn. let hostile = "switched to \u{1b}[2J\u{1b}[10;10H\u{07}/work/pwned"; let mut state = sample_state(); - state.notices = vec![hostile.to_string()]; + state.active.notices = vec![hostile.to_string()]; let mut terminal = Terminal::new(TestBackend::new(100, 12)).unwrap(); terminal @@ -481,10 +477,10 @@ fn content_pane_applies_the_vertical_scroll_offset() { // With content_scroll = N (and no wrap), the first visible content row is line N: the // lines above it are scrolled off the top. let mut state = sample_state(); - state.notices = vec![]; // no notice strip, so content starts at the inner top row - state.content = to_text("c0\nc1\nc2\nc3\nc4\nc5\nc6\nc7\n"); - state.wrap = false; - state.content_scroll = 3; + state.active.notices = vec![]; // no notice strip, so content starts at the inner top row + state.active.content = std::sync::Arc::new(to_text("c0\nc1\nc2\nc3\nc4\nc5\nc6\nc7\n")); + state.active.wrap = false; + state.active.scroll = 3; let out = render(&state, 100, 12); assert!(out.contains("c3"), "the scrolled-to line is visible\n{out}"); assert!(out.contains("c7"), "lines below it are visible\n{out}"); @@ -505,7 +501,7 @@ fn tree_scrolls_to_keep_selection_visible() { use ratatui::layout::Rect; use ratatui::style::Modifier; let mut state = sample_state(); - state.notices = vec![]; + state.active.notices = vec![]; // 40 files — far more than fit the ~22-row tree interior at 100x24. state.nodes = (0..40) .map(|i| { @@ -604,7 +600,7 @@ fn geometry_reports_the_tree_scroll_offset_for_hit_testing() { // Many nodes, selection near the end: the window scrolls and keeps the selection visible. let mut many = sample_state(); - many.notices = vec![]; + many.active.notices = vec![]; many.nodes = (0..40) .map(|i| { node( @@ -641,7 +637,7 @@ fn tree_shows_a_vertical_scrollbar_only_when_it_overflows() { ); let mut many = sample_state(); - many.notices = vec![]; + many.active.notices = vec![]; many.nodes = (0..40) .map(|i| { node( @@ -677,7 +673,7 @@ fn tree_shows_a_horizontal_scrollbar_and_scrolls_long_rows() { height: 24, }; let mut state = sample_state(); - state.notices = vec![]; + state.active.notices = vec![]; let long = format!("START_{}_END", "x".repeat(60)); state.nodes = vec![node(&format!("/r/{long}"), NodeKind::File, 0, false, None)]; state.selected = 0; @@ -739,7 +735,7 @@ fn scrollbars_are_inside_the_pane_with_a_one_column_gap() { height: 24, }; let mut state = sample_state(); - state.notices = vec![]; + state.active.notices = vec![]; // Overflow both ways: many rows (vbar) + one very wide row (hbar). state.nodes = (0..40) .map(|i| { @@ -795,14 +791,14 @@ fn content_pane_shows_a_vertical_scrollbar_when_content_overflows() { // The content pane gets a vertical scrollbar when it has more lines than the viewport is tall. // The tree (7 nodes) does NOT overflow a 12-row frame, so the only ▐ is the content scrollbar. let mut state = sample_state(); - state.notices = vec![]; - state.content = to_text( + state.active.notices = vec![]; + state.active.content = std::sync::Arc::new(to_text( &(0..60) .map(|i| format!("line{i}")) .collect::>() .join("\n"), - ); - state.content_rows = 60; // the controller's rendered-row count drives the vertical bar + )); + state.active.rows = 60; // the controller's rendered-row count drives the vertical bar let out = render(&state, 100, 12); assert!( out.contains('▐'), @@ -810,8 +806,8 @@ fn content_pane_shows_a_vertical_scrollbar_when_content_overflows() { ); // A short file shows none. - state.content = to_text("only\ntwo\n"); - state.content_rows = 2; + state.active.content = std::sync::Arc::new(to_text("only\ntwo\n")); + state.active.rows = 2; let short = render(&state, 100, 12); assert!( !short.contains('▐'), @@ -826,18 +822,18 @@ fn content_vertical_scrollbar_is_driven_by_rendered_rows_not_raw_lines() { // presenter sizes the bar from `content_rows` (the controller's rendered-row count), so a single // raw line with a large content_rows shows a bar, and a small content_rows shows none. let mut state = sample_state(); - state.notices = vec![]; - state.content = to_text(&"word ".repeat(400)); // ONE raw line - state.wrap = true; + state.active.notices = vec![]; + state.active.content = std::sync::Arc::new(to_text(&"word ".repeat(400))); // ONE raw line + state.active.wrap = true; - state.content_rows = 60; // wraps to ~60 rows >> the ~22-row viewport + state.active.rows = 60; // wraps to ~60 rows >> the ~22-row viewport let overflow = render(&state, 100, 24); assert!( overflow.contains('▐'), "a single long line that wraps past the viewport shows a vertical scrollbar\n{overflow}" ); - state.content_rows = 5; // wraps to only 5 rows → fits + state.active.rows = 5; // wraps to only 5 rows → fits let fits = render(&state, 100, 24); assert!( !fits.contains('▐'), @@ -860,7 +856,7 @@ fn tree_vertical_thumb_tracks_the_selection() { height: 24, }; let mut state = sample_state(); - state.notices = vec![]; + state.active.notices = vec![]; state.nodes = (0..40) .map(|i| { node( @@ -906,16 +902,16 @@ fn content_vertical_scrollbar_thumb_reaches_the_bottom_at_max_scroll() { height: h, }; let mut state = sample_state(); - state.notices = vec![]; + state.active.notices = vec![]; // 60 lines into a 16-row text area → max scroll 44. - state.content = to_text( + state.active.content = std::sync::Arc::new(to_text( &(0..60) .map(|i| format!("line {i}")) .collect::>() .join("\n"), - ); - state.wrap = false; - state.content_rows = 60; // the rendered-row count drives the vertical bar + )); + state.active.wrap = false; + state.active.rows = 60; // the rendered-row count drives the vertical bar let track = geometry(area, &state) .content_vbar .expect("content vbar present"); @@ -925,7 +921,7 @@ fn content_vertical_scrollbar_thumb_reaches_the_bottom_at_max_scroll() { .collect() }; - state.content_scroll = 0; + state.active.scroll = 0; let top = thumb_rows(&render_buffer(&state, w, h)); assert!( top.contains(&track.y), @@ -936,7 +932,7 @@ fn content_vertical_scrollbar_thumb_reaches_the_bottom_at_max_scroll() { "thumb is NOT at the bottom at scroll 0" ); - state.content_scroll = 44; // the max scroll for this content/viewport + state.active.scroll = 44; // the max scroll for this content/viewport let bottom = thumb_rows(&render_buffer(&state, w, h)); assert!( bottom.contains(&(track.y + track.height - 1)), @@ -951,17 +947,17 @@ fn content_pane_shows_a_horizontal_scrollbar_for_a_too_wide_unwrapped_line() { // light border (─), so it is an unambiguous marker. Wrapping the same line removes the // overflow, so no horizontal scrollbar is drawn. let mut state = sample_state(); - state.notices = vec![]; - state.content = to_text(&"x".repeat(300)); // far wider than the ~58-col content pane + state.active.notices = vec![]; + state.active.content = std::sync::Arc::new(to_text(&"x".repeat(300))); // far wider than the ~58-col content pane - state.wrap = false; + state.active.wrap = false; let unwrapped = render(&state, 100, 24); assert!( unwrapped.contains('▄'), "a too-wide unwrapped line shows a horizontal scrollbar (▄)\n{unwrapped}" ); - state.wrap = true; + state.active.wrap = true; let wrapped = render(&state, 100, 24); assert!( !wrapped.contains('▄'), @@ -984,7 +980,7 @@ fn tree_scroll_is_sticky_when_the_selection_stays_visible() { height: 24, }; let mut state = sample_state(); - state.notices = vec![]; + state.active.notices = vec![]; state.nodes = (0..40) .map(|i| { node( @@ -1030,12 +1026,12 @@ fn wrapping_shows_more_of_a_long_line_than_truncating() { // it. (The sample tree/title carry no 'W', so every 'W' counted comes from the content.) let long = "W ".repeat(80); // ~160 cols, far wider than the ~58-col content pane let mut state = sample_state(); - state.notices = vec![]; - state.content = to_text(&long); + state.active.notices = vec![]; + state.active.content = std::sync::Arc::new(to_text(&long)); - state.wrap = true; + state.active.wrap = true; let wrapped = render(&state, 100, 12); - state.wrap = false; + state.active.wrap = false; let truncated = render(&state, 100, 12); let ws = |s: &str| s.matches('W').count(); @@ -1101,7 +1097,7 @@ fn row_fg(buf: &ratatui::buffer::Buffer, needle: &str) -> ratatui::style::Color fn tree_rows_are_colored_by_git_status() { use ratatui::style::Color; let mut state = sample_state(); - state.notices = vec![]; + state.active.notices = vec![]; state.nodes = vec![ // A directory that contains changes (dir_dirty), a modified file, a new file, a clean // file. The clean file is selected so the colored rows aren't reversed (which would @@ -1166,10 +1162,10 @@ fn content_pane_applies_the_horizontal_scroll_offset() { // With content_hscroll = N (and no wrap), the leftmost N columns are scrolled off, so a // long line shows from column N onward. let mut state = sample_state(); - state.notices = vec![]; - state.content = to_text("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"); - state.wrap = false; - state.content_hscroll = 5; + state.active.notices = vec![]; + state.active.content = std::sync::Arc::new(to_text("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")); + state.active.wrap = false; + state.active.hscroll = 5; let out = render(&state, 100, 12); assert!( out.contains("FGHIJ"), @@ -1423,6 +1419,34 @@ fn min_tree_split_pct_yields_at_least_the_min_columns() { ); } +#[test] +fn split_preview_policy_keeps_prompt_below_remote_status_outside_body() { + use herdr_file_viewer::preview_layout::{LayoutInput, PreviewFocus, layout}; + + let layout = layout( + LayoutInput::new(ratatui::layout::Rect::new(4, 8, 100, 12)) + .with_prompt(true) + .with_remote_status(true) + .with_pin(true) + .with_focus(PreviewFocus::Active), + ); + + assert_eq!( + layout.prompt, + Some(ratatui::layout::Rect::new(4, 19, 100, 1)) + ); + assert_eq!( + layout.remote_status, + Some(ratatui::layout::Rect::new(4, 18, 100, 1)) + ); + assert_eq!(layout.body, ratatui::layout::Rect::new(4, 8, 100, 10)); + assert!( + layout + .active + .is_some_and(|active| active.bottom() <= layout.remote_status.unwrap().y) + ); +} + #[test] fn geometry_allows_a_narrow_manual_tree_on_a_very_wide_pane() { // Medium-fix: on an ultrawide pane the tree floor is pane-aware, so a manually-narrowed (or @@ -1491,9 +1515,9 @@ fn content_pad_left_insets_the_transformed_views_one_column() { height: 24, }; let mut flush = sample_state(); - flush.content_pad_left = false; + flush.active.pad_left = false; let mut padded = sample_state(); - padded.content_pad_left = true; + padded.active.pad_left = true; let x_flush = geometry(area, &flush).content_inner.unwrap().x; let x_padded = geometry(area, &padded).content_inner.unwrap().x; @@ -1525,9 +1549,9 @@ fn content_pad_left_shifts_the_drawn_text_one_column() { // renders both and compares the first content glyph's column. Zoomed so content fills the frame. fn first_content_glyph_x(pad: bool) -> u16 { let mut st = sample_state(); - st.notices = vec![]; + st.active.notices = vec![]; st.zoomed = true; - st.content_pad_left = pad; + st.active.pad_left = pad; let mut terminal = Terminal::new(TestBackend::new(40, 6)).unwrap(); terminal .draw(|f| { @@ -2937,7 +2961,7 @@ fn go_to_line_no_file_notice_renders_for_ac7() { // longer shows an "unavailable" notice — confirming there auto-switches and jumps.) This verifies // the Presenter surfaces that notice via the existing notices channel, with no new code path. let mut st = sample_state(); - st.notices = vec!["Go to line: select a file first".into()]; + st.active.notices = vec!["Go to line: select a file first".into()]; let out = render(&st, 100, 24); assert!( out.contains("Go to line: select a file first"), @@ -2955,15 +2979,15 @@ fn go_to_line_no_file_notice_renders_for_ac7() { fn search_state() -> ViewState { use herdr_file_viewer::render::to_text; let mut st = sample_state(); - st.notices = vec![]; + st.active.notices = vec![]; // Content: exactly three lines whose text is predictable byte-by-byte. // "fn main() {\n println!(\"hello\");\n}\n" (from sample_state, but override) // We use simple ASCII-only content so byte offsets are trivial. - st.content = to_text("fn main() {\n println!;\n}\n"); - st.content_rows = 3; + st.active.content = std::sync::Arc::new(to_text("fn main() {\n println!;\n}\n")); + st.active.rows = 3; // match 0 = "main" on line 0, bytes 3..7 // match 1 = "}" on line 2, bytes 0..1 → current (current = 1) - st.search = Some(ContentSearch { + st.active.search = Some(ContentSearch { matches: vec![ Match { line: 0, @@ -3116,11 +3140,13 @@ fn search_highlight_snapshot() { fn line_select_state(marker: usize, start: usize, end: usize) -> ViewState { use herdr_file_viewer::render::to_text; let mut st = sample_state(); - st.notices = vec![]; + st.active.notices = vec![]; st.focus = Focus::Content; - st.content = to_text("line one\nline two\nline three\nline four\nline five\nline six\n"); - st.content_rows = 6; - st.line_select = Some(LineSelectView { + st.active.content = std::sync::Arc::new(to_text( + "line one\nline two\nline three\nline four\nline five\nline six\n", + )); + st.active.rows = 6; + st.active.line_select = Some(LineSelectView { marker, start, end, @@ -3155,11 +3181,13 @@ fn passive_range_highlight_has_no_caret_gutter() { // Launch open-target range flash: whole-line HIGHLIGHT only — no ▶/│ column (not line-select mode). use herdr_file_viewer::render::to_text; let mut st = sample_state(); - st.notices = vec![]; + st.active.notices = vec![]; st.focus = Focus::Content; - st.content = to_text("line one\nline two\nline three\nline four\nline five\nline six\n"); - st.content_rows = 6; - st.line_select = Some(LineSelectView { + st.active.content = std::sync::Arc::new(to_text( + "line one\nline two\nline three\nline four\nline five\nline six\n", + )); + st.active.rows = 6; + st.active.line_select = Some(LineSelectView { marker: 2, start: 2, end: 4, @@ -3266,11 +3294,11 @@ fn ambient_selection_highlights_only_selected_chars_without_shifting_content() { // Content "line one\n…"; select chars [5, 8) of line 1 → "one" (gutter 0, no bat gutter). let mut st = sample_state(); - st.notices = vec![]; + st.active.notices = vec![]; st.focus = Focus::Content; - st.content = to_text("line one\nline two\nline three\n"); - st.content_rows = 3; - st.content_selection = Some(CharSelView { + st.active.content = std::sync::Arc::new(to_text("line one\nline two\nline three\n")); + st.active.rows = 3; + st.active.selection = Some(CharSelView { start_line: 1, start_col: 5, end_line: 1, @@ -3702,12 +3730,12 @@ fn selecting_a_directory_shows_empty_state_guidance_not_a_blank_pane() { let mut state = sample_state(); // Select the directory row (index 0 = /r/src). state.selected = 0; - state.content = to_text("Directory: select a file to view"); - state.notices.clear(); + state.active.content = std::sync::Arc::new(to_text("Directory: select a file to view")); + state.active.notices.clear(); // a directory selection has no displayed file content, so the title falls back to // the selected node's name (the directory). Mirrors the controller's `clear_content`. - state.content_title = None; - state.content_rendering = false; + state.active.title = None; + state.active.rendering = false; let out = render(&state, 100, 24); assert!( out.contains("Directory: select a file to view"), @@ -3722,11 +3750,11 @@ fn an_empty_tree_shows_empty_state_guidance_not_a_blank_pane() { let mut state = sample_state(); state.nodes = vec![]; // empty tree state.selected = 0; - state.content = to_text("No files"); - state.notices.clear(); + state.active.content = std::sync::Arc::new(to_text("No files")); + state.active.notices.clear(); // no file content displayed → title falls back to "Content" (no selected node). - state.content_title = None; - state.content_rendering = false; + state.active.title = None; + state.active.rendering = false; let out = render(&state, 100, 24); assert!( out.contains("No files"), @@ -3739,11 +3767,11 @@ fn empty_state_directory_snapshot() { // Snapshot the directory-selected empty state so the layout + the guidance are locked. let mut state = sample_state(); state.selected = 0; // the /r/src directory - state.content = to_text("Directory: select a file to view"); - state.notices.clear(); + state.active.content = std::sync::Arc::new(to_text("Directory: select a file to view")); + state.active.notices.clear(); // directory selected → no file content → title falls back to the directory's name. - state.content_title = None; - state.content_rendering = false; + state.active.title = None; + state.active.rendering = false; insta::assert_snapshot!("presenter_empty_directory", render(&state, 100, 24)); } @@ -3753,11 +3781,11 @@ fn empty_state_no_files_snapshot() { let mut state = sample_state(); state.nodes = vec![]; state.selected = 0; - state.content = to_text("No files"); - state.notices.clear(); + state.active.content = std::sync::Arc::new(to_text("No files")); + state.active.notices.clear(); // no file content displayed → title falls back to "Content" (no selected node). - state.content_title = None; - state.content_rendering = false; + state.active.title = None; + state.active.rendering = false; insta::assert_snapshot!("presenter_empty_no_files", render(&state, 100, 24)); } @@ -3773,10 +3801,10 @@ fn loading_state_snapshot_while_a_render_is_in_flight() { // The cursor has moved to README.md (index 4) but its render hasn't landed — the body is the // placeholder and `content_title` is `None` (no content has landed at all yet, e.g. launch). state.selected = 4; // README.md - state.content = to_text("Rendering\u{2026}"); - state.notices.clear(); - state.content_title = None; - state.content_rendering = true; + state.active.content = std::sync::Arc::new(to_text("Rendering\u{2026}")); + state.active.notices.clear(); + state.active.title = None; + state.active.rendering = true; insta::assert_snapshot!("presenter_loading", render(&state, 100, 24)); } @@ -3787,11 +3815,11 @@ fn annotation_content_state( ranges: Vec, ) -> ViewState { let mut state = sample_state(); - state.notices.clear(); + state.active.notices.clear(); state.zoomed = true; state.focus = Focus::Content; - state.content_rows = lines.len().min(u16::MAX as usize) as u16; - state.content = ratatui::text::Text::from(lines); + state.active.rows = lines.len().min(u16::MAX as usize) as u16; + state.active.content = std::sync::Arc::new(ratatui::text::Text::from(lines)); state.annotation_indicators.displayed_file_annotated = true; state.annotation_indicators.displayed_line_ranges = ranges; state @@ -3804,7 +3832,7 @@ fn annotation_tree_markers_preserve_git_width_foregrounds_and_selection_style() use ratatui::style::{Color, Modifier}; let mut state = sample_state(); - state.notices.clear(); + state.active.notices.clear(); state.nodes = vec![ node("/r/clean.rs", NodeKind::File, 0, false, None), node( @@ -3870,10 +3898,10 @@ fn annotation_tree_markers_preserve_git_width_foregrounds_and_selection_style() #[test] fn annotation_applied_content_title_is_sanitized_then_marked_in_zoom_and_transformed_views() { let mut state = sample_state(); - state.notices.clear(); + state.active.notices.clear(); state.zoomed = true; - state.content_pad_left = true; - state.content_title = Some("ma\u{1b}[2Jin.rs".to_string()); + state.active.pad_left = true; + state.active.title = Some("ma\u{1b}[2Jin.rs".to_string()); state.annotation_indicators.displayed_file_annotated = true; let out = render(&state, 40, 8); assert!( @@ -3881,14 +3909,14 @@ fn annotation_applied_content_title_is_sanitized_then_marked_in_zoom_and_transfo "trusted marker follows sanitization\n{out}" ); - state.content_title = None; - state.content_rendering = true; + state.active.title = None; + state.active.rendering = true; let loading = render(&state, 40, 8); assert!( !loading.contains("@Content"), "an unapplied selection stays neutral" ); - state.content_rendering = false; + state.active.rendering = false; state.nodes[state.selected].kind = NodeKind::Dir; let directory = render(&state, 40, 8); assert!( @@ -3955,7 +3983,7 @@ fn annotation_blank_line_cell_tracks_horizontal_vertical_and_wrapped_rows_bounde vec![Line::raw("x".repeat(100)), Line::raw("")], vec![LineRange::new(2, 2).unwrap()], ); - horizontal.content_hscroll = 40; + horizontal.active.hscroll = 40; let horizontal_area = Rect::new(0, 0, 30, 8); let horizontal_text = geometry(horizontal_area, &horizontal) .content_inner @@ -3982,8 +4010,8 @@ fn annotation_blank_line_cell_tracks_horizontal_vertical_and_wrapped_rows_bounde .collect(), vec![LineRange::new(5, 5).unwrap()], ); - vertical.content_rows = 10; - vertical.content_scroll = 4; + vertical.active.rows = 10; + vertical.active.scroll = 4; let vertical_area = Rect::new(0, 0, 30, 6); let vertical_text = geometry(vertical_area, &vertical).content_inner.unwrap(); let vertical_buf = render_buffer(&vertical, 30, 6); @@ -4000,8 +4028,8 @@ fn annotation_blank_line_cell_tracks_horizontal_vertical_and_wrapped_rows_bounde vec![Line::raw("w".repeat(30)), Line::raw("")], vec![LineRange::new(2, 2).unwrap()], ); - wrapped.wrap = true; - wrapped.content_rows = 3; + wrapped.active.wrap = true; + wrapped.active.rows = 3; let wrapped_area = Rect::new(0, 0, 20, 8); let wrapped_text = geometry(wrapped_area, &wrapped).content_inner.unwrap(); let wrapped_buf = render_buffer(&wrapped, 20, 8); @@ -4028,8 +4056,8 @@ fn annotation_blank_line_cell_tracks_horizontal_vertical_and_wrapped_rows_bounde (0..100).map(|_| Line::raw("")).collect(), vec![LineRange::new(1, 100).unwrap()], ); - bounded.content_rows = 100; - bounded.content_scroll = 50; + bounded.active.rows = 100; + bounded.active.scroll = 50; let bounded_area = Rect::new(0, 0, 20, 6); let bounded_text = geometry(bounded_area, &bounded).content_inner.unwrap(); let bounded_buf = render_buffer(&bounded, 20, 6); @@ -4054,7 +4082,7 @@ fn annotation_blank_lines_compose_exact_line_select_and_ambient_styles() { vec![Line::raw(""), Line::raw("")], vec![LineRange::new(1, 2).unwrap()], ); - state.line_select = Some(LineSelectView { + state.active.line_select = Some(LineSelectView { marker: 1, start: 1, end: 2, @@ -4078,8 +4106,8 @@ fn annotation_blank_lines_compose_exact_line_select_and_ambient_styles() { assert_eq!(range.bg, Color::Cyan); assert_eq!(range.fg, Color::Black); - state.line_select = None; - state.content_selection = Some(CharSelView { + state.active.line_select = None; + state.active.selection = Some(CharSelView { start_line: 1, start_col: 0, end_line: 1, @@ -4104,7 +4132,7 @@ fn annotation_nonblank_active_overlays_have_exact_precedence_and_preserve_wrappe vec![Line::raw("alpha"), Line::raw("beta")], vec![LineRange::new(1, 2).unwrap()], ); - state.line_select = Some(LineSelectView { + state.active.line_select = Some(LineSelectView { marker: 1, start: 1, end: 2, @@ -4124,8 +4152,8 @@ fn annotation_nonblank_active_overlays_have_exact_precedence_and_preserve_wrappe assert_eq!(selected_text.bg, Color::Cyan); assert_eq!(selected_text.fg, Color::Black); - state.line_select = None; - state.content_selection = Some(CharSelView { + state.active.line_select = None; + state.active.selection = Some(CharSelView { start_line: 1, start_col: 1, end_line: 1, @@ -4137,8 +4165,8 @@ fn annotation_nonblank_active_overlays_have_exact_precedence_and_preserve_wrappe assert_eq!(ambient.cell((text.x + 1, text.y)).unwrap().bg, Color::Cyan); assert_eq!(ambient.cell((text.x + 1, text.y)).unwrap().fg, Color::Black); - state.content_selection = None; - state.search = Some(ContentSearch { + state.active.selection = None; + state.active.search = Some(ContentSearch { matches: vec![ Match { line: 0, @@ -4168,8 +4196,8 @@ fn annotation_nonblank_active_overlays_have_exact_precedence_and_preserve_wrappe vec![Line::raw("w".repeat(45))], vec![LineRange::new(1, 1).unwrap()], ); - wrapped.wrap = true; - wrapped.content_rows = 2; + wrapped.active.wrap = true; + wrapped.active.rows = 2; let wrapped_text = geometry(area, &wrapped).content_inner.unwrap(); let wrapped_buf = render_buffer(&wrapped, 30, 8); assert_eq!( @@ -4201,7 +4229,7 @@ fn annotation_active_overlay_branches_keep_line_select_and_ambient_ahead_of_sear vec![Line::raw("alpha"), Line::raw("beta")], vec![LineRange::new(1, 2).unwrap()], ); - state.search = Some(ContentSearch { + state.active.search = Some(ContentSearch { matches: vec![Match { line: 0, start: 0, @@ -4209,7 +4237,7 @@ fn annotation_active_overlay_branches_keep_line_select_and_ambient_ahead_of_sear }], current: 0, }); - state.line_select = Some(LineSelectView { + state.active.line_select = Some(LineSelectView { marker: 2, start: 1, end: 2, @@ -4227,8 +4255,8 @@ fn annotation_active_overlay_branches_keep_line_select_and_ambient_ahead_of_sear "line-select's non-current range style wins over the simultaneous current search match" ); - state.line_select = None; - state.content_selection = Some(CharSelView { + state.active.line_select = None; + state.active.selection = Some(CharSelView { start_line: 1, start_col: 0, end_line: 1, @@ -4625,7 +4653,7 @@ fn a_compacted_directory_row_renders_its_chain_label() { // `compact_dirs` folds a run of single-child directories into one row whose Node carries a // `label` (`src/main/java`); the tree must draw THAT, not the path's last component alone. let mut state = sample_state(); - state.notices = vec![]; + state.active.notices = vec![]; state.selected = 1; state.nodes = vec![ Node { diff --git a/tests/presenter_narrow.rs b/tests/presenter_narrow.rs index f6ffe621..1e0a1cb2 100644 --- a/tests/presenter_narrow.rs +++ b/tests/presenter_narrow.rs @@ -6,8 +6,9 @@ use herdr_file_viewer::annotation::LineRange; use herdr_file_viewer::git::Status; use herdr_file_viewer::presenter::{ AnnotationEditorKind, AnnotationEditorView, AnnotationIndicatorsView, AnnotationOverviewView, - AnnotationRowView, AnnotationTargetView, Focus, ViewState, draw, + AnnotationRowView, AnnotationTargetView, Focus, PreviewProjection, ViewState, draw, }; +use herdr_file_viewer::preview_layout::{LayoutInput, PreviewFocus, layout}; use herdr_file_viewer::render::to_text; use herdr_file_viewer::tree::{Node, NodeKind}; use ratatui::Terminal; @@ -27,6 +28,8 @@ fn node(path: &str, kind: NodeKind, depth: usize, status: Option) -> Nod } fn state(width: u16, focus: Focus) -> ViewState { + let mut active = PreviewProjection::new("main.rs", to_text("fn main() {}\n")); + active.notices = vec!["delta not found — showing plain diff".to_string()]; ViewState { nodes: vec![ node("/r/src", NodeKind::Dir, 0, None), @@ -34,18 +37,13 @@ fn state(width: u16, focus: Focus) -> ViewState { node("/r/scratch.log", NodeKind::File, 0, Some(Status::Untracked)), ], selected: 1, - content: to_text("fn main() {}\n"), - notices: vec!["delta not found — showing plain diff".to_string()], - flash: None, + active, + pinned: None, focus, width, - content_scroll: 0, - content_hscroll: 0, tree_scroll: 0, tree_hscroll: 0, - content_rows: 1, // the fixture content is one line - wrap: false, - content_pad_left: false, + preview_split_pct: 50, split_pct: 40, tree_position: herdr_file_viewer::config::TreePosition::Left, tree_max_cols: 1000, // high cap: percentage governs (narrow-layout tests ignore it anyway) @@ -61,12 +59,8 @@ fn state(width: u16, focus: Focus) -> ViewState { annotation_indicators: AnnotationIndicatorsView::default(), root_name: "r".to_string(), // the fixture tree is rooted at /r branch: None, + pinned_foreign_root: None, prompt: None, - content_title: Some("main.rs".to_string()), - content_rendering: false, - search: None, - line_select: None, - content_selection: None, help: None, } } @@ -139,6 +133,47 @@ fn wide_shows_both_columns_regardless_of_focus() { ); } +#[test] +fn structural_policy_keeps_no_pin_boundary_and_pin_floor_distinct() { + use ratatui::layout::Rect; + + for (width, tree, active) in [(79, true, false), (80, true, true)] { + let result = layout(LayoutInput::new(Rect::new(0, 0, width, 10))); + assert_eq!(result.tree.is_some(), tree, "no pin at {width} columns"); + assert_eq!(result.active.is_some(), active, "no pin at {width} columns"); + } + + for (width, adjacent) in [(83, false), (84, true)] { + let result = layout(LayoutInput { + area: Rect::new(0, 0, width, 10), + has_pin: true, + tree_hidden: true, + focus: PreviewFocus::Pinned, + ..LayoutInput::new(Rect::default()) + }); + assert_eq!( + result.pinned.is_some() && result.active.is_some(), + adjacent, + "each 50/50 preview needs 40 interior columns at {width} columns" + ); + if !adjacent { + let without_pin = layout(LayoutInput { + has_pin: false, + ..LayoutInput { + area: Rect::new(0, 0, width, 10), + has_pin: true, + tree_hidden: true, + focus: PreviewFocus::Pinned, + ..LayoutInput::new(Rect::default()) + } + }); + assert_eq!(result.pinned, None); + assert_eq!(result.active, without_pin.active); + assert!(result.pinned_hidden_for_space); + } + } +} + #[test] fn split_decision_follows_the_live_frame_not_a_stale_state_width() { // The narrow/wide decision must come from the frame the Presenter actually draws into, @@ -181,7 +216,7 @@ fn narrow_content_snapshot() { fn annotation_title_marker_remains_visible_with_tree_hidden_in_narrow_and_zoom_layouts() { let mut narrow = state(60, Focus::Content); narrow.annotation_indicators.displayed_file_annotated = true; - narrow.content_pad_left = true; + narrow.active.pad_left = true; let narrow_out = render(&narrow, 60, 10); assert!( narrow_out.contains("@main.rs"), diff --git a/tests/presenter_pinned.rs b/tests/presenter_pinned.rs new file mode 100644 index 00000000..01989be9 --- /dev/null +++ b/tests/presenter_pinned.rs @@ -0,0 +1,278 @@ +//! Pinned and active preview projections share one drawing path. + +use herdr_file_viewer::presenter::{ + AnnotationIndicatorsView, CharSelView, ContentSearch, Focus, PreviewProjection, ViewState, + draw, geometry, +}; +use herdr_file_viewer::preview::{BranchState, PreviewOrigin}; +use herdr_file_viewer::render::to_text; +use herdr_file_viewer::search::Match; +use ratatui::{Terminal, backend::TestBackend}; +use std::path::PathBuf; + +fn projection(title: &str, content: &str) -> PreviewProjection { + PreviewProjection::new(title, to_text(content)) +} + +fn state(pinned: PreviewProjection) -> ViewState { + let mut active = projection("active.rs", "ACTIVE needle\nactive second row\n"); + active.notices = vec!["active notice".into()]; + active.search = Some(ContentSearch { + matches: vec![Match { + line: 1, + start: 7, + end: 13, + }], + current: 0, + }); + active.selection = Some(CharSelView { + start_line: 2, + start_col: 0, + end_line: 2, + end_col: 6, + gutter: 0, + }); + ViewState { + nodes: Vec::new(), + selected: 0, + active, + pinned: Some(pinned), + focus: Focus::Content, + width: 150, + tree_scroll: 0, + tree_hscroll: 0, + preview_split_pct: 50, + split_pct: 20, + tree_position: herdr_file_viewer::config::TreePosition::Left, + tree_max_cols: 20, + split_manual: false, + zoomed: false, + remote_notice_status: None, + picker: None, + finder: None, + annotation_count: 0, + annotation_overview: None, + annotation_editor: None, + discard_confirm: None, + annotation_indicators: AnnotationIndicatorsView::default(), + root_name: "r".into(), + branch: None, + pinned_foreign_root: None, + prompt: None, + help: None, + } +} + +/// The same fixture with the pin marked as captured in ANOTHER worktree, which is the only case +/// that earns the worktree label in the title (AC-12). +fn state_from_foreign_worktree(pinned: PreviewProjection, worktree: &str) -> ViewState { + ViewState { + pinned_foreign_root: Some(worktree.to_string()), + ..state(pinned) + } +} + +fn render( + state: &ViewState, + width: u16, + height: u16, +) -> (String, herdr_file_viewer::presenter::PreviewViewports) { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let mut viewports = Default::default(); + terminal + .draw(|frame| { + viewports = draw(frame, state); + }) + .unwrap(); + (format!("{}", terminal.backend()), viewports) +} + +fn origin(root: &str, branch: BranchState, path: &str) -> PreviewOrigin { + PreviewOrigin::new( + PathBuf::from(root), + branch, + PathBuf::from(root).join(path), + PathBuf::from(path), + ) +} + +#[test] +fn wide_three_region_snapshot_and_independent_viewports() { + let mut pinned = projection("ignored by origin", "PINNED needle\npinned second row\n"); + pinned.notices = vec!["pinned notice".into()]; + pinned.wrap = true; + pinned.rows = 4; + pinned.search = Some(ContentSearch { + matches: vec![Match { + line: 1, + start: 7, + end: 13, + }], + current: 0, + }); + pinned.origin = Some(origin( + "/worktrees/review", + BranchState::Named("review".into()), + "src/pinned.rs", + )); + let mut state = state(pinned); + state.preview_split_pct = 40; + let (output, viewports) = render(&state, 150, 16); + + assert!( + output.contains("PINNED") && output.contains("ACTIVE"), + "{output}" + ); + assert!( + output.contains("pinned notice") && output.contains("active notice"), + "{output}" + ); + assert!( + viewports.pinned.is_some(), + "pinned viewport feedback is named" + ); + assert_ne!( + viewports.active, + viewports.pinned.unwrap(), + "each projection measures its own viewport" + ); + insta::assert_snapshot!("wide_three_region", output); +} + +#[test] +fn tree_hidden_keeps_both_preview_projections() { + let mut pinned = projection("pinned.rs", "PINNED\n"); + pinned.origin = Some(origin( + "/repo", + BranchState::Named("main".into()), + "pinned.rs", + )); + let mut state = state(pinned); + state.zoomed = true; + let (output, viewports) = render(&state, 100, 12); + + assert!( + output.contains("PINNED") && output.contains("ACTIVE"), + "{output}" + ); + assert!(viewports.pinned.is_some() && viewports.active.0 > 0); + insta::assert_snapshot!("tree_hidden_two_preview", output); +} + +#[test] +fn narrow_pin_keeps_active_projection_and_surfaces_widen_notice() { + let mut pinned = projection("pinned.rs", "PINNED NARROW\n"); + pinned.origin = Some(origin( + "/worktrees/review", + BranchState::Named("review".into()), + "pinned.rs", + )); + let mut state = state(pinned); + state.focus = Focus::Pinned; + let (output, viewports) = render(&state, 60, 12); + + assert!(!output.contains("PINNED NARROW"), "{output}"); + assert!(output.contains("ACTIVE needle"), "{output}"); + assert!( + output.contains("Pinned: pinned.rs — widen to view"), + "{output}" + ); + assert!(viewports.pinned.is_none()); + assert_ne!(viewports.active, (0, 0)); +} + +#[test] +fn narrow_pin_focus_snapshots() { + for (name, focus) in [ + ("narrow_tree", Focus::Tree), + ("narrow_pinned", Focus::Pinned), + ("narrow_active", Focus::Content), + ] { + let mut pinned = projection("pinned.rs", "PINNED\n"); + pinned.origin = Some(origin( + "/worktrees/review", + BranchState::Named("review".into()), + "pinned.rs", + )); + let mut state = state(pinned); + state.focus = focus; + let (output, _) = render(&state, 60, 12); + insta::assert_snapshot!(name, output); + } +} + +#[test] +fn pinned_origin_identity_is_visible_and_neutralized() { + // The third element is the foreign worktree label: `None` means the pin came from the viewed + // worktree, where the branch alone is the origin and no worktree is named. + let cases = [ + ( + "origin_named_branch", + origin( + "/worktrees/feature", + BranchState::Named("feat/one".into()), + "src/lib.rs", + ), + None, + ), + ( + "origin_detached", + origin("/worktrees/detached", BranchState::Detached, "README.md"), + None, + ), + ( + "origin_cross_worktree", + origin( + "/worktrees/other\u{1b}[31m", + BranchState::Named("topic\u{7}".into()), + "src/other.rs", + ), + Some("other\u{1b}[31m"), + ), + ]; + for (name, origin, foreign) in cases { + let mut pinned = projection("ignored", "PINNED\n"); + pinned.origin = Some(origin); + let state = match foreign { + Some(worktree) => state_from_foreign_worktree(pinned, worktree), + None => state(pinned), + }; + let (output, _) = render(&state, 150, 10); + assert!( + !output.contains('\u{1b}') && !output.contains('\u{7}'), + "{output}" + ); + assert!( + !output.contains("src/lib.rs") + && !output.contains("README.md") + && !output.contains("src/other.rs"), + "AC-12: the pinned title contains origin only, not its path\n{output}" + ); + // A same-worktree pin must not carry a worktree at all: repeating the viewed worktree's + // own name is the width that clipped the origin away before this amendment. + assert_eq!( + output.contains("Pinned: [feat/one]") || output.contains("Pinned: [detached]"), + foreign.is_none(), + "AC-12: only a cross-worktree pin names its worktree\n{output}" + ); + insta::assert_snapshot!(name, output); + } +} + +#[test] +fn prompt_and_remote_status_stay_full_width_and_unique_with_a_pin() { + let mut pinned = projection("pinned.rs", "PINNED\n"); + pinned.origin = Some(origin( + "/repo", + BranchState::Named("main".into()), + "pinned.rs", + )); + let mut state = state(pinned); + state.prompt = Some("Search: needle".into()); + state.remote_notice_status = Some("Update available".into()); + let (output, _) = render(&state, 150, 12); + assert_eq!(output.matches("Search: needle").count(), 1, "{output}"); + assert_eq!(output.matches("Update available").count(), 1, "{output}"); + let layout = geometry(ratatui::layout::Rect::new(0, 0, 150, 12), &state); + assert_eq!(layout.area_width, 150); +} diff --git a/tests/remote_notice_startup.rs b/tests/remote_notice_startup.rs index 7d082b92..ceb865a9 100644 --- a/tests/remote_notice_startup.rs +++ b/tests/remote_notice_startup.rs @@ -240,7 +240,7 @@ fn every_remote_failure_is_silent_in_the_controller_presenter_projection() { "{name}: no remote diagnostic reaches the frame" ); assert!( - ctrl.view_state().notices.is_empty(), + ctrl.view_state().active.notices.is_empty(), "{name}: no notice strip" ); assert!(ctrl.view_state().prompt.is_none(), "{name}: no prompt"); diff --git a/tests/reroot.rs b/tests/reroot.rs index 09386c29..2f3ecfdc 100644 --- a/tests/reroot.rs +++ b/tests/reroot.rs @@ -13,9 +13,11 @@ use herdr_file_viewer::controller::{ RenderResult, RootProviders, }; use herdr_file_viewer::git::{Baseline, Status}; +use herdr_file_viewer::infile::SearchState; use herdr_file_viewer::intent::Intent; use herdr_file_viewer::presenter::Focus; use herdr_file_viewer::root::Resolved; +use herdr_file_viewer::search::Match; use herdr_file_viewer::tree::NodeKind; use herdr_file_viewer::view_policy::ViewMode; use ratatui::text::Text; @@ -188,7 +190,8 @@ fn re_root_rebuilds_at_the_new_root_carrying_prefs_and_resetting_nav() { ctrl.handle(Intent::GrowTree); // tree-resize → split_pct changes off its default ctrl.handle(Intent::ToggleWrap); // `w` → wrap_override true ctrl.handle(Intent::ToggleChangedOnly); // changed-only on (git repo → takes effect) - ctrl.handle(Intent::ToggleIgnore); // show-ignored on + ctrl.apply_show_ignored(true); // config/startup preference carried through a re-root + ctrl.apply_compact_dirs(true); // likewise: fresh trees keep their compact shape ctrl.handle(Intent::ToggleHidden); // hide-hidden on (#46) ctrl.handle(Intent::ToggleBaseline); // baseline Head → Base @@ -226,6 +229,31 @@ fn re_root_rebuilds_at_the_new_root_carrying_prefs_and_resetting_nav() { ctrl.tree().cursor() > 0, "cursor moved off the root row before re_root" ); + // A settled snapshot is root-independent reference state: T-13 keeps it, with its ratio, + // while all active/root-bound state below resets. + poll_until(&mut ctrl, Duration::from_secs(5), |c| { + c.active_document().is_some() + }); + { + let interaction = ctrl.active_interaction_mut(); + interaction.vertical_scroll = 7; + interaction.horizontal_scroll = 3; + interaction.search = Some(SearchState { + query: "fake".into(), + matches: vec![Match { + line: 0, + start: 0, + end: 4, + }], + current: 0, + }); + } + assert!(ctrl.handle(Intent::PinPreview).redraw); + let pinned_before = ctrl + .view_state() + .pinned + .expect("precondition: preview is pinned"); + assert_eq!(ctrl.view_state().preview_split_pct, 50); ctrl.handle(Intent::ToggleChangedOnly); // changed-only back on (the carried pref state) ctrl.handle(Intent::ToggleZoom); // zoom on, focus → content assert!(ctrl.zoomed()); @@ -235,8 +263,8 @@ fn re_root_rebuilds_at_the_new_root_carrying_prefs_and_resetting_nav() { // be flipped off below to inspect the real filesystem tree). --- let b = TempDir::new(); common::init_repo_with_commit(b.path()); - std::fs::create_dir_all(b.path().join("bsub")).unwrap(); - std::fs::write(b.path().join("bsub/inner.txt"), "inner\n").unwrap(); + std::fs::create_dir_all(b.path().join("bsub/inner")).unwrap(); + std::fs::write(b.path().join("bsub/inner/deep.txt"), "inner\n").unwrap(); std::fs::write(b.path().join("b.txt"), "b\n").unwrap(); ctrl.re_root(b.path()); @@ -251,13 +279,82 @@ fn re_root_rebuilds_at_the_new_root_carrying_prefs_and_resetting_nav() { assert!(ctrl.wrap_override(), "wrap_override carried"); assert!(ctrl.changed_only(), "changed_only carried"); assert!(ctrl.show_ignored(), "show_ignored carried"); + assert!( + ctrl.tree().show_ignored(), + "fresh tree kept show-ignored preference" + ); assert!(ctrl.hide_hidden(), "hide_hidden carried (#46)"); assert_eq!(ctrl.baseline(), Baseline::Base, "baseline carried"); + let pinned_after = ctrl.view_state().pinned.expect("pin survives re_root"); + assert_eq!( + pinned_after.content, pinned_before.content, + "frozen content" + ); + assert_eq!( + pinned_after.notices, pinned_before.notices, + "content notices" + ); + assert_eq!(pinned_after.origin, pinned_before.origin, "captured origin"); + // AC-12: the frozen snapshot is untouched by the switch, but its TITLE now has a worktree to + // distinguish, so the label appears where it was absent before. It names the worktree the pin + // was captured in, not the one now being viewed. + let origin_root = pinned_before + .origin + .as_ref() + .expect("pinned origin precondition") + .root() + .to_path_buf(); + assert_eq!( + ctrl.view_state().pinned_foreign_root, + origin_root + .file_name() + .map(|name| name.to_string_lossy().into_owned()), + "a pin from the previous worktree names that worktree after the switch" + ); + assert_eq!(pinned_after.scroll, pinned_before.scroll, "vertical scroll"); + assert_eq!( + pinned_after.hscroll, pinned_before.hscroll, + "horizontal scroll" + ); + match (&pinned_after.search, &pinned_before.search) { + (Some(actual), Some(expected)) => { + assert_eq!(actual.matches, expected.matches, "search matches"); + assert_eq!(actual.current, expected.current, "current search match"); + } + _ => panic!("re-root changed pinned search presence"), + } + assert_eq!( + ctrl.view_state().preview_split_pct, + 50, + "pin ratio survives re_root" + ); - // Navigation/view state is RESET (AC-13). + // Navigation/view state is RESET (AC-13), but the frozen pin's query is still available + // when focus returns to it after the switch. assert_eq!(ctrl.tree().cursor(), 0, "cursor back at the root row"); assert!(!ctrl.zoomed(), "unzoomed after re_root"); assert_eq!(ctrl.focus(), Focus::Tree, "focus back on the tree"); + ctrl.set_preview_viewports(herdr_file_viewer::presenter::PreviewViewports { + active: (8, 4), + pinned: Some((8, 4)), + }); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Content); + ctrl.handle(Intent::ToggleFocus); + assert_eq!(ctrl.focus(), Focus::Pinned); + assert!( + ctrl.view_state() + .prompt + .as_deref() + .is_some_and(|status| status.contains("Search: fake (1/1)")), + "captured pinned search query survives the re-root" + ); + ctrl.handle(Intent::ToggleFocus); + assert_eq!( + ctrl.focus(), + Focus::Tree, + "focus cycle returns to the reset tree focus" + ); // the git-derived state (status markers + the changed-only filter built from the // changed-set) now fills in ASYNCHRONOUSLY, applied by `poll` rather than synchronously in @@ -285,6 +382,11 @@ fn re_root_rebuilds_at_the_new_root_carrying_prefs_and_resetting_nav() { !nodes.iter().any(|n| n.expanded), "no expansions carried into the new tree" ); + assert_eq!( + root_child.label.as_deref(), + Some("bsub/inner"), + "the re-rooted tree retained compact_dirs" + ); } /// A factory that, for the root whose path ends in `b_dir_name`, hands back a [`CannedGit`] diff --git a/tests/snapshots/presenter_pinned__narrow_active.snap b/tests/snapshots/presenter_pinned__narrow_active.snap new file mode 100644 index 00000000..a4aa9703 --- /dev/null +++ b/tests/snapshots/presenter_pinned__narrow_active.snap @@ -0,0 +1,16 @@ +--- +source: tests/presenter_pinned.rs +expression: output +--- +"┌active.rs─────────────────────────────────────────────────┐" +"│active notice │" +"│Pinned: pinned.rs — widen to view │" +"│ACTIVE needle │" +"│active second row │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└────────────────────────────────────────────────────? help┘" diff --git a/tests/snapshots/presenter_pinned__narrow_pinned.snap b/tests/snapshots/presenter_pinned__narrow_pinned.snap new file mode 100644 index 00000000..a4aa9703 --- /dev/null +++ b/tests/snapshots/presenter_pinned__narrow_pinned.snap @@ -0,0 +1,16 @@ +--- +source: tests/presenter_pinned.rs +expression: output +--- +"┌active.rs─────────────────────────────────────────────────┐" +"│active notice │" +"│Pinned: pinned.rs — widen to view │" +"│ACTIVE needle │" +"│active second row │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└────────────────────────────────────────────────────? help┘" diff --git a/tests/snapshots/presenter_pinned__narrow_tree.snap b/tests/snapshots/presenter_pinned__narrow_tree.snap new file mode 100644 index 00000000..7f77c07e --- /dev/null +++ b/tests/snapshots/presenter_pinned__narrow_tree.snap @@ -0,0 +1,16 @@ +--- +source: tests/presenter_pinned.rs +expression: output +--- +"┌r─────────────────────────────────────────────────────────┐" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└──────────────────────────────────────────────────────────┘" diff --git a/tests/snapshots/presenter_pinned__origin_cross_worktree.snap b/tests/snapshots/presenter_pinned__origin_cross_worktree.snap new file mode 100644 index 00000000..28973f5b --- /dev/null +++ b/tests/snapshots/presenter_pinned__origin_cross_worktree.snap @@ -0,0 +1,14 @@ +--- +source: tests/presenter_pinned.rs +expression: output +--- +"┌r─────────────────┐┌active.rs──────────────────────────────────────────────────────┐┌Pinned: [topic @ other[31m]────────────────────────────────────┐" +"│ ││active notice ││PINNED │" +"│ ││ACTIVE needle ││ │" +"│ ││active second row ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"└──────────────────┘└─────────────────────────────────────────────────────────? help┘└───────────────────────────────────────────────────────────────┘" diff --git a/tests/snapshots/presenter_pinned__origin_detached.snap b/tests/snapshots/presenter_pinned__origin_detached.snap new file mode 100644 index 00000000..0b776395 --- /dev/null +++ b/tests/snapshots/presenter_pinned__origin_detached.snap @@ -0,0 +1,14 @@ +--- +source: tests/presenter_pinned.rs +expression: output +--- +"┌r─────────────────┐┌active.rs──────────────────────────────────────────────────────┐┌Pinned: [detached]─────────────────────────────────────────────┐" +"│ ││active notice ││PINNED │" +"│ ││ACTIVE needle ││ │" +"│ ││active second row ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"└──────────────────┘└─────────────────────────────────────────────────────────? help┘└───────────────────────────────────────────────────────────────┘" diff --git a/tests/snapshots/presenter_pinned__origin_named_branch.snap b/tests/snapshots/presenter_pinned__origin_named_branch.snap new file mode 100644 index 00000000..c732ca6f --- /dev/null +++ b/tests/snapshots/presenter_pinned__origin_named_branch.snap @@ -0,0 +1,14 @@ +--- +source: tests/presenter_pinned.rs +expression: output +--- +"┌r─────────────────┐┌active.rs──────────────────────────────────────────────────────┐┌Pinned: [feat/one]─────────────────────────────────────────────┐" +"│ ││active notice ││PINNED │" +"│ ││ACTIVE needle ││ │" +"│ ││active second row ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"└──────────────────┘└─────────────────────────────────────────────────────────? help┘└───────────────────────────────────────────────────────────────┘" diff --git a/tests/snapshots/presenter_pinned__tree_hidden_two_preview.snap b/tests/snapshots/presenter_pinned__tree_hidden_two_preview.snap new file mode 100644 index 00000000..3c968482 --- /dev/null +++ b/tests/snapshots/presenter_pinned__tree_hidden_two_preview.snap @@ -0,0 +1,16 @@ +--- +source: tests/presenter_pinned.rs +expression: output +--- +"┌active.rs───────────────────────────────────────┐┌Pinned: [main]──────────────────────────────────┐" +"│active notice ││PINNED │" +"│ACTIVE needle ││ │" +"│active second row ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"└──────────────────────────────────────────? help┘└────────────────────────────────────────────────┘" diff --git a/tests/snapshots/presenter_pinned__wide_three_region.snap b/tests/snapshots/presenter_pinned__wide_three_region.snap new file mode 100644 index 00000000..56ad157d --- /dev/null +++ b/tests/snapshots/presenter_pinned__wide_three_region.snap @@ -0,0 +1,20 @@ +--- +source: tests/presenter_pinned.rs +expression: output +--- +"┌r─────────────────┐┌active.rs───────────────────────────────────────────────────────────────────┐┌Pinned: [review]──────────────────────────────────┐" +"│ ││active notice ││pinned notice │" +"│ ││ACTIVE needle ││PINNED needle │" +"│ ││active second row ││pinned second row │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"│ ││ ││ │" +"└──────────────────┘└──────────────────────────────────────────────────────────────────────? help┘└──────────────────────────────────────────────────┘"