diff --git a/docs/INDEX.md b/docs/INDEX.md index ae130506..3867674f 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -62,6 +62,7 @@ Maps subsystems and topics to relevant documentation files and source paths. Use - `docs/rfc/workon-review.md` - `docs/adr/033-review-crate-workspace-placement.md` +- `docs/adr/038-review-focus-maximize-replaces-zoom.md` — the diff pane's view state, and why `Role::Whole` survives - Key source: `git-workon-review/src/` ## Cross-cutting Concerns diff --git a/docs/adr/033-review-crate-workspace-placement.md b/docs/adr/033-review-crate-workspace-placement.md index 4986768e..0aa0f31a 100644 --- a/docs/adr/033-review-crate-workspace-placement.md +++ b/docs/adr/033-review-crate-workspace-placement.md @@ -9,18 +9,18 @@ The RFC (`docs/rfc/workon-review.md`) defines `git-workon-review`, a standalone Add `git-workon-review` as a sibling crate: lib target `workon_review`, bin target `git-workon-review`. - **`publish = false`** in the crate's `Cargo.toml` is the single knob that keeps it out of both release-plz and cargo-dist, following the `git-workon-fixture` precedent (proven across ~20 releases). -- **`[package.metadata.dist] dist = false`** is set explicitly as well. It is redundant today (`publish = false` already excludes the crate) but is the tripwire for the M3 flip: removing `publish = false` alone, without also deciding this field, would silently make cargo-dist ship the binary. +- **`[package.metadata.dist] dist = false`** is set explicitly as well. It is redundant today (`publish = false` already excludes the crate) but is the tripwire for the initial-renderer flip: removing `publish = false` alone, without also deciding this field, would silently make cargo-dist ship the binary. - **Independent versioning**: the crate does not join `version_group = "main"` in `release-plz.toml`. Joining would lockstep its version to the CLI's and cross-bump the CLI on every review-crate change. - **Workspace `rust-version` bumped to `1.88`** (ratatui 0.30's floor). `clap` 4.6 already required 1.85, so the workspace's previous `1.68.2` declaration was already unsatisfiable in practice; only the fixture crate had ever inherited the field. `rust-version.workspace = true` is added to all four crates so the field is real everywhere. ## Consequences -- The crate builds and tests in CI from the start (M0) without appearing in crates.io or in any cargo-dist release artifact. -- The M3 flip (when the review binary is ready to distribute) requires: +- The crate builds and tests in CI from the start (crate scaffolding) without appearing in crates.io or in any cargo-dist release artifact. +- The initial-renderer flip (when the review binary is ready to distribute) requires: 1. Remove `publish = false` from `git-workon-review/Cargo.toml`. 2. Add `[[package]] name = "git-workon-review"` to `release-plz.toml`, with **no** `version_group` — independent versioning is intentional, not an oversight to fix later. 3. Keep `dist = false` until binary distribution is designed. The homebrew publish job in `.github/workflows/release.yml` patches **every** `Formula/*.rb` with `git-workon`'s man page and completions install lines; it must be reworked before a second binary can safely flow through it. `release-plz.yml`'s `dist` dispatch step is also hardcoded to fire only for `package_name == "git-workon"` and needs updating too. -- Until the M3 flip, the crate's version in its own `Cargo.toml` is cosmetic — release-plz never touches it. +- Until the initial-renderer flip, the crate's version in its own `Cargo.toml` is cosmetic — release-plz never touches it. ## References diff --git a/docs/adr/034-review-git-native-config-schema.md b/docs/adr/034-review-git-native-config-schema.md index f25c9405..575f9198 100644 --- a/docs/adr/034-review-git-native-config-schema.md +++ b/docs/adr/034-review-git-native-config-schema.md @@ -3,7 +3,7 @@ ## Context The review TUI (`git-workon-review`) grew its keybindings and colors as hardcoded -values during M3–M5: a `match` in `tui.rs` for keys, a block of `const … Color::Rgb(…)` +values during the initial-renderer-through-stack-and-outline work: a `match` in `tui.rs` for keys, a block of `const … Color::Rgb(…)` atop `render.rs` for theming. Making either user-configurable needs a config home, and the review binary reads no config today (`struct Cli {}` is empty). @@ -32,8 +32,8 @@ is stored **action-as-key** in **per-view subsections**: ``` workon.review.theme = dark ; global, non-view -workon.review.theme. = #rrggbb ; base00-base0f override (CS1) -workon.review.theme. = #rrggbb ; diff/cursor tint override (CS1) +workon.review.theme. = #rrggbb ; base00-base0f override (git-config reader) +workon.review.theme. = #rrggbb ; diff/cursor tint override (git-config reader) workon.review..bind. = "" ; a keymap entry workon.review.. = ; view config ``` @@ -60,8 +60,8 @@ workon.review.. = ; view config - **View config** (non-binding) shares the view namespace: `workon.review.outline.width`, `workon.review.outline.mode`, `workon.review.diff.layout`, `workon.review.diff.zoom`. The `.bind.` marker is what distinguishes a keymap entry from a view setting. -- **Theme overrides** (CS1, user-configurable colors tier — see - [ADR-035](035-review-theming-base16-hybrid.md)'s CS1 revision) live in the `review.theme` +- **Theme overrides** (the git-config reader, user-configurable colors tier — see + [ADR-035](035-review-theming-base16-hybrid.md)'s git-config-reader revision) live in the `review.theme` subsection, distinct from the top-level `workon.review.theme` selection itself: `workon.review .theme.base00`–`workon.review.theme.base0f` (base16 slot overrides) and eleven kebab-case tint keys (`workon.review.theme.cursor-bg`, …). Same validation posture as an unknown bind @@ -100,8 +100,8 @@ workon.review.. = ; view config - Like all git-native config (ADR-006), review config is **not checked into the repo**, so a team cannot ship a shared review keymap/theme in-tree. Accepted: this is a personal-productivity TUI. -- The per-view namespace gives previously-hardcoded view settings (outline width — M5 - deferred narrow-terminal handling — outline mode, diff layout/zoom defaults) a natural +- The per-view namespace gives previously-hardcoded view settings (outline width — the + stack-and-outline work's deferred narrow-terminal handling — outline mode, diff layout/zoom defaults) a natural home without a second design pass. - Adding a rebindable action = adding it to the enumerable action set (code default + dispatch + help entry); it is automatically configurable, validated, and documented. @@ -148,6 +148,6 @@ enough, since the overwhelmingly common cause of an unknown key is a typo of a r ## References - [ADR-006](006-git-native-config.md) — git-native config under `workon.*` this extends -- `docs/rfc/workon-review.md` — RFC; this is the everyday-usability pass inserted ahead of M7 +- `docs/rfc/workon-review.md` — RFC; this is the everyday-usability pass inserted ahead of the source-selector work - `git-workon-review/src/tui.rs` — current hardcoded keymap (`map_key`) being replaced - `git-workon-review/src/render.rs` — current hardcoded palette (`const … Color::Rgb`) — see the theming decision diff --git a/docs/adr/035-review-theming-base16-hybrid.md b/docs/adr/035-review-theming-base16-hybrid.md index 2a6f51ee..4cde4ecc 100644 --- a/docs/adr/035-review-theming-base16-hybrid.md +++ b/docs/adr/035-review-theming-base16-hybrid.md @@ -2,9 +2,9 @@ ## Context -The review TUI's colors were hardcoded during M3–M5: a `const … Color::Rgb(…)` block +The review TUI's colors were hardcoded during the initial-renderer-through-stack-and-outline work: a `const … Color::Rgb(…)` block atop `render.rs` (dark-only) and a parallel `HIGHLIGHT_NAMES`/`HIGHLIGHT_COLORS` pair in -`highlight.rs`. The everyday-usability pass (ahead of M7, see [ADR-034](034-review-git-native-config-schema.md)) +`highlight.rs`. The everyday-usability pass (ahead of the source-selector work, see [ADR-034](034-review-git-native-config-schema.md)) adds built-in light/dark theming and terminal adaptivity. Four things had to be resolved: the color *philosophy* (respect the terminal's 16 ANSI colors vs. ship tuned truecolor), the theme *primitive*, the *mechanism* by which a theme reaches syntax highlighting, and @@ -30,7 +30,7 @@ is spec-conformant. variants, cursor, selection, and **syntax**. Contrast is guaranteed because foreground and background come from the *same* scheme. - **Chrome (default text, dim labels, gutter/dividers) + the canvas background → - base16-ramp-controlled (revised post-CS6):** originally these were ANSI-named + base16-ramp-controlled (revised after the terminal-derivation-probe work):** originally these were ANSI-named (`Color::Gray`/`DarkGray`) and the canvas was never painted, on the theory that inheriting the terminal's own bg/fg would self-adapt for free. In practice this broke explicit `light`/`dark` selections outright — the terminal's own (often dark) bg/fg bled straight @@ -51,17 +51,20 @@ capture→slot template. Diff-bg tints ideally come from base08 (red / spec "Diff Deleted") and base0B (green / spec "Diff Inserted") and the scheme background, so syntax and tints stay coordinated. **But the -derivation is luminance-dependent, not a single "blend toward base00" (corrected in CS4):** -- **Dark (base00 dark):** the shipped M3–M5 tints are more saturated/darker than *any* convex +derivation is luminance-dependent, not a single "blend toward base00" (corrected in the +base16-palette-primitive work):** +- **Dark (base00 dark):** the shipped initial-renderer-through-stack-and-outline tints are more saturated/darker than *any* convex blend of an accent toward a dark base00 can produce (their green/blue channels sit *below* base00's). A blend toward a dark base00 also yields muddy mid-tones, not punchy washes. So - the **dark tints are held explicit** in `Palette::dark()` (byte-identical to M3–M5, per the + the **dark tints are held explicit** in `Palette::dark()` (byte-identical to the + initial-renderer-through-stack-and-outline values, per the pixel-identity gate). Deriving them would require scaling the accent toward *black* plus a desaturation step, not a base00 blend — not worth reverse-engineering the hand-tuned values. - **Light (base00 light) and terminal-derived:** blending an accent toward a *light* base00 - gives the correct pale tint, so the `tint_toward` derivation applies there (CS5/CS6). A + gives the correct pale tint, so the `tint_toward` derivation applies there (the + curated-light-scheme and terminal-derivation-probe work). A terminal-derived theme on a *dark* background hits the same problem as dark and needs the - toward-black+desaturate construction — a CS6 concern. + toward-black+desaturate construction — a terminal-derivation-probe concern. Net: the scheme-coordinated derivation is real but must branch on background luminance; dark stays authored. @@ -100,7 +103,7 @@ stays authored. the curated scheme chosen by background luminance if `OSC 11` answered, else `dark`. tmux/screen/ssh non-response is handled by the timeout, never a hang. -**CS6 refinement — the diff-bg tints stay curated, only the scheme is derived.** In +**Terminal-derivation-probe refinement — the diff-bg tints stay curated, only the scheme is derived.** In implementation, `auto` derives the base16 **scheme** (the 16 slots → syntax + monochrome ramp) from the terminal, but the **diff/cursor/selection tints stay curated by luminance** rather than derived from the probed accents (`Palette::from_terminal`: syntax = `SYNTAX_SLOTS` over the probed @@ -117,7 +120,7 @@ ANSI-less slots are still synthesized as above; `parse` → `build_base16` → ` read left untested (see `terminal_query.rs`). **Derived-washes addendum (2026-07-20) — `auto`'s diff washes derive from the probed accents -after all; cursor/selection washes stay curated.** The CS6 refinement above is partially +after all; cursor/selection washes stay curated.** The terminal-derivation-probe refinement above is partially reversed. Its objection (1) — "a convex blend toward a dark base00 can't reproduce the hand-tuned washes" — turned out to answer the wrong question: the goal isn't to reproduce the curated washes from probed inputs, it's to produce the washes the terminal's *theme author* @@ -127,7 +130,7 @@ computes its editor diff backgrounds as `accent:mix(bg, 90)` — exactly the `tint_toward(accent, bg, k)` shape. `Palette::from_terminal` now derives del washes from probed base08 and add washes from probed base0B toward the probed base00: a dark probed background uses the dogfood-validated ratios (subtle 0.90, strong 0.75, staged 0.94/0.85 — staged still reads -dimmer, locked decision #7), a light one reuses `Palette::light`'s hand-tuned ratio set. +dimmer, per the staging-verbs staged-attribution decision), a light one reuses `Palette::light`'s hand-tuned ratio set. Objection (2) — arbitrary-palette unpredictability — is accepted residual risk, bounded by the `workon.review.theme.*` override tier (a wash that derives badly on some exotic palette is pinnable per-user). Cursor/selection/unfocused washes keep borrowing the curated set: they have @@ -150,7 +153,7 @@ surprises (a teal cursor row on an aqua-leaning theme), so that judgment stays c to render; `FgSpan` loses its `Color` field in favor of a capture index. Existing render tests that assert concrete colors must resolve through a fixed test `Palette`. -## Revised (CS2, visual-polish pass) +## Revised (promoting semantic foregrounds to palette knobs, visual-polish pass) The "chrome that is never a theme knob (error/warn/current-marker) stays ANSI/const in `render.rs`" clause above is superseded. Those three colors are now `Palette` fields @@ -162,7 +165,7 @@ precedent the diff/cursor tints follow); `light()` takes `ONE_LIGHT`'s base08/ba the syntax slots (matching the terminal, not curated-tint-borrowing). No other part of the hybrid boundary changes: this only moves three named colors from `const` to palette fields. -## Revised (CS1, user-configurable colors tier) +## Revised (user-configurable color-override keys) The "user-supplied base16 scheme … the deferred 'user-configurable colors' tier" noted in Consequences above lands, narrower than originally sketched: **per-slot and per-tint git-config @@ -191,7 +194,7 @@ resolved — the mechanism is base-agnostic, so an override key works identicall role-mapped field(s) even when the current base hand-authored that field explicitly (e.g. `base08` under `theme = dark` replaces `dark()`'s hand-tuned `error_fg`) — the alternative (silently ignoring slot overrides for authored fields) is a UX trap: a user who sets `base08` -expects red to change. Slot overrides do NOT re-derive the diff/cursor tints; that stays the 11 +expects red to change. Slot overrides do NOT re-derive the diff/cursor tints; that stays the 12 tint keys' job, applied last and verbatim, so a slot override can't reshape a hand-tuned wash it wasn't asked to touch. An invalid value or an unrecognized key under `workon.review.theme.*` is ignored with a startup warning (the same posture as ADR-034's keybinding validation) — not a @@ -203,7 +206,7 @@ to vendor) was considered and set aside — the override-key tier covers the imm named schemes slot in additively later (a `Theme::Named` variant + a `schemes.rs` of vendored constants) without touching this work if demand appears. -**NO_COLOR (CS2).** The other extra this tier's Context section named — `NO_COLOR`, no CLI flag, +**NO_COLOR (monochrome rendering).** The other extra this tier's Context section named — `NO_COLOR`, no CLI flag, no color-depth downgrade — lands as `Palette::mono(light: bool)`: every fg field, every syntax entry, and the canvas background collapse to `Color::Reset` (`paint_canvas: false`), while the 11 diff/cursor washes become achromatic grayscale `Rgb` ladders (dark-terminal vs light-terminal @@ -225,9 +228,9 @@ a palette-EXTERNAL color source `mono()`'s own `Color::Reset` fields can't reach carries a `colorless` flag (`false` on every curated/probed constructor, `true` only on `mono`) and `render.rs`'s icon paint sites collapse to `foreground` themselves whenever it's set. -## Revised (CS11, diff foreground/background split) +## Revised (diff foreground/background split) -CS1's override table above named the diff washes `subtle`/`strong`. That naming is retired: it +The user-configurable-color-override-keys work's override table above named the diff washes `subtle`/`strong`. That naming is retired: it described *intensity*, and intensity names invite reuse wherever something should look emphatic. `render.rs`'s outline status column duly reached for `add_strong`/`del_strong` as **foregrounds** for the X/Y letters — a background wash used as text color. On a theme whose washes are dark (the @@ -277,7 +280,7 @@ then has no background signal, and the foreground is dimming against a full-stre derivation dims by up to the nominal ratio and stops early at a relative-luminance floor against that state's own edit wash. This is the first real contrast math in `theme.rs`, whose only prior arithmetic was `tint_toward`'s per-channel lerp; it is worth the ~25 lines because the failure it -prevents is silent and theme-dependent. Note this is *not* the CS1 blend trap — that was about a +prevents is silent and theme-dependent. Note this is *not* the user-configurable-color-override-keys blend trap — that was about a convex blend being unable to *reproduce* `dark()`'s hand-tuned washes (channels below base00); blending an accent toward base00 for a foreground is well-defined, and `light()` already does it. @@ -288,7 +291,7 @@ text also retints the status column. Accepted; a theme wanting them apart can be appears. **`workon.review.diff.text` selects the foreground source on changed lines** — `syntax` (default, -pixel-identical to CS1 behavior), `tint` (changed lines take the tint foreground), `edit` (syntax +pixel-identical to the user-configurable-color-override-keys behavior), `tint` (changed lines take the tint foreground), `edit` (syntax stays on the line; only edits take the tint foreground). Context lines always keep syntax highlighting in every mode; `NO_COLOR`/`mono` still wins over all of it, unchanged. In `edit` mode an unpaired line takes the tint foreground across its full width, preserving the invariant @@ -308,7 +311,7 @@ nothing, now documented rather than surprising. existing unknown-key startup warning. Pre-1.0, and a dual vocabulary would keep the retired model discoverable — which is the thing this revision exists to undo. -**Corrections to CS1's table above:** it says "the 11 tint keys" while listing 12, and omits +**Corrections to the user-configurable-color-override-keys table above:** it said "the 11 tint keys" while listing 12 (fixed above to 12), and omits `filler-fg` entirely (added later, when the filler hatch was screened back to its own base01 foreground). The table in this revision supersedes it for the diff keys; `cursor-bg`, `selection-bg`, `cursor-unfocused-bg`, `pane-header-focused-fg`, and `filler-fg` are unchanged and diff --git a/docs/adr/036-review-source-grammar.md b/docs/adr/036-review-source-grammar.md index 6028891c..6247cff9 100644 --- a/docs/adr/036-review-source-grammar.md +++ b/docs/adr/036-review-source-grammar.md @@ -1,11 +1,11 @@ # 036 — Review Source: One Sniffed Positional, Shape-Aware Resolution -Status: accepted (2026-07-09, M7 design session) +Status: accepted (2026-07-09, source-selector design session) ## Context -Through M6.5 the review binary's `Cli` is empty: it reviews only what auto-detect finds -(the Graphite stack when one is active, else a single uncommitted changeset). M7 makes it +Through the usability pass the review binary's `Cli` is empty: it reviews only what auto-detect finds +(the Graphite stack when one is active, else a single uncommitted changeset). The source-selector work makes it review *anything* — the RFC's "review any source" — which forces three intertwined decisions: how a source is spelled on the command line, what changeset(s) each spelling resolves to, and what happens when resolution fails. This is the binary's entire @@ -34,10 +34,11 @@ only the exact bare word matches the keyword. **`stack` keyword** — "give me the real stack": Graphite metadata when active, otherwise git-inference (the lib's already-built `StackModel::Git` arm: one changeset per commit in `upstream..HEAD`). No metadata and no upstream is a real error, never a silent fall-through -to uncommitted — an explicit ask deserves an explicit failure. This ships the M5-deferred -`StackModel::Git` wiring, scoped to the one keyword that means it. +to uncommitted — an explicit ask deserves an explicit failure. This ships the +stack-and-outline-deferred `StackModel::Git` wiring, scoped to the one keyword that means it. -**`uncommitted` keyword** — always the single uncommitted changeset (M2–M4 behavior), +**`uncommitted` keyword** — always the single uncommitted changeset (the +diff-model-and-patch-synthesis-through-staging-verbs behavior), even in a Graphite repo. **`` — shape-aware dispatch.** Match what a person most plausibly means per shape: @@ -76,13 +77,13 @@ to name the source. **Completion — keywords + local branches + tags.** Offline git2 ref enumeration only; after a `..`/`...` prefix, complete the right-hand ref the same way. No PR-number -completion (network in the TAB hot path). This is M6's deferred sub-delegation trigger: +completion (network in the TAB hot path). This is the CLI-integration work's deferred sub-delegation trigger: git-workon's dynamic completer now shells out to `COMPLETE= git-workon-review` for post-subcommand words. **Rename `ChangesetSource` → `ChangesetSpan`.** Its doc comment already says "what a Changeset spans"; the rename frees "source" for the user-facing concept every roadmap -document already uses. Safe while the M1–M6.5 tower is unmerged. +document already uses. Safe while the lib-changeset-assembly-through-usability-pass tower is unmerged. ## Consequences @@ -90,7 +91,7 @@ document already uses. Safe while the M1–M6.5 tower is unmerged. (Auto | Stack | Uncommitted | Ref | Range | Pr) becomes the seam between CLI parse and changeset resolution. - Stack assembly for a non-HEAD tracked branch must suppress the uncommitted layer — a - lib-side knob or an acquire-side filter (execution detail, see the M7 plan). + lib-side knob or an acquire-side filter (execution detail, see the source-selector plan). - `review ` resolves through the untracked-branch arm via its upstream (unpushed commits) — an acceptable edge, not a special case. - Git-inference changesets become reachable from the binary for the first time; its diff --git a/docs/adr/037-review-progressive-pipeline.md b/docs/adr/037-review-progressive-pipeline.md index 3d3d950d..d47feddf 100644 --- a/docs/adr/037-review-progressive-pipeline.md +++ b/docs/adr/037-review-progressive-pipeline.md @@ -4,12 +4,12 @@ Status: accepted (2026-07-10, progressive-pipeline design session) ## Context -The M7 performance pass (perf-gt-detect … perf-pty-responsiveness) removed the worst +The source-selector performance pass (perf-gt-detect … perf-pty-responsiveness) removed the worst launch and navigation stalls, but two synchronous gaps remain: the idle-deferred file load runs on the event-loop thread (a huge file holds input hostage for its own load once the 80ms debounce fires), and startup diffs complete in full — behind the splash, but not streamed — before the outline appears. Closing them means work moves off the event-loop -thread, which **supersedes M4's locked decision #4** ("a synchronous poll on the existing +thread, which **supersedes the staging-verbs work's sync-runtime decision** ("a synchronous poll on the existing `Tick`… No threads, no `mpsc`, no new deps" — recorded in `tui.rs`'s module doc, not an ADR). This ADR retires the "no threads" letter of that decision while keeping its "no new deps" spirit: everything below is `std::sync::mpsc` + `std::thread`. Zero new dependencies. @@ -72,7 +72,7 @@ chokepoint keeps its meaning: an action that reads the view (`s`, cursor moves, finds the cache warm or loads *synchronously right there* — `App` keeps its own `Repository` + `TsHighlighter` for exactly this and for staging. The in-flight loader result later hits "already cached" and is discarded. The loader is thereby a **pure -cache-warmer: correctness never depends on it**, and the CS4 invariant (deferred-then- +cache-warmer: correctness never depends on it**, and the idle-deferred-file-loads invariant (deferred-then- completed open ≡ eager open, byte-identical) survives trivially. Accepted cost: `s` on a just-reached huge file can still block for that file's load — the price of byte-identical action semantics without action-replay machinery (queueing actions until `FileReady` was @@ -130,10 +130,10 @@ stay untouched (defer off, slots constructed `Ready`). ## Consequences -- `tui.rs`'s module doc note pinning M4 locked decision #4 must be rewritten to point - here; the M4 index-watcher *semantics* (signature compare on the tick beat, echo - suppression) are unchanged — only the beat's mechanism moves from `event::poll` timeout - to `recv_timeout`. +- The sync-poll comments in `app.rs` and `tui.rs` pinning the staging-verbs work's + sync-runtime decision must be rewritten to point here; that work's index-watcher + *semantics* (signature compare on the tick beat, echo suppression) are unchanged — only + the beat's mechanism moves from `event::poll` timeout to `recv_timeout`. - `AppEvent` stops being `Copy`; `drain_pending`/`next_event` reshape around the inbox; the input thread becomes the only code that touches crossterm's event API. - The splash survives only on the lone-changeset path; for stacks the first frame is the @@ -143,3 +143,14 @@ stay untouched (defer off, slots constructed `Ready`). a corrupt changeset in a stack degrades to a `Failed` row instead of killing the launch. - `App` and the loader each hold a `TsHighlighter`; grammar caches are duplicated per-instance (modest, accepted for the sync-fallback guarantee). +- The loader thread's `Repository` is long-lived, and libgit2 caches a repository's index + in memory without ever re-reading it from disk. So index state a loader job reads goes + stale the moment the main thread stages: the handle keeps serving the index as it stood + when some earlier job on it first looked. Found in practice after this ADR landed, as a + staged file rendering its gutter with no text (`read_index_blob` returned the pre-stage + blob, so the staged view's new side came back shorter than its own hunks). It reproduced + only for a stage started from the outline, since the diff pane's own staging verbs run + the force-completion fallback above and rebuild on `App`'s handle, which just did the + write. `read_index_blob` now calls `git_index_read(force = false)` before every read. + Any future loader-side read of index state needs the same treatment: the force-completion + fallback covers correctness only for what the main thread actually re-reads. diff --git a/docs/adr/038-review-focus-maximize-replaces-zoom.md b/docs/adr/038-review-focus-maximize-replaces-zoom.md new file mode 100644 index 00000000..bb7c5236 --- /dev/null +++ b/docs/adr/038-review-focus-maximize-replaces-zoom.md @@ -0,0 +1,183 @@ +# 038 — Review TUI: Replace the Zoom Enum with Focus + Maximize + +Status: accepted (2026-08-09, pre-merge review of the initial-renderer-through-in-diff-navigation tower) + +## Context + +The diff pane has three `Role`s, each naming a pair of trees: `Combined` (HEAD ↔ worktree), +`Unstaged` (index ↔ worktree), and `Staged` (HEAD ↔ index). On top of that sits a four-variant +`Zoom` — `Split`, `Combined`, `Unstaged`, `Staged` — cycled with `Z`, settable from +`workon.review.diff.zoom`, and resolved per file by `effective_zoom` against the sub-diffs that +file actually has. + +Alongside it, and independent of it, `split_focus` tracks which half of a split has focus, toggled +with `w`. + +Three findings from dogfooding through the in-diff navigation work and from reading the state space: + +**`Zoom::Combined` earns nothing.** Staging verbs refuse there — every verb writes the index, and +the index is on neither side of HEAD ↔ worktree — so the state is read-only by construction, and +`Zoom::Split` shows the same changes already separated by the axis under review, with verbs that +work. It has never been reached deliberately in daily use. + +**`Zoom::Unstaged` and `Zoom::Staged` are not view modes.** The split renders as +`caption(1) + unstaged content + caption(1) + staged content` with the remainder halved evenly, and +there is no resize or collapse. Those two states exist to escape the fixed 50/50 split and give one +role the whole body. That is a maximize, described as an independent state. + +**The two mechanisms overlap and can disagree.** `Zoom` and `split_focus` are separate fields, so +"zoomed to Unstaged while focused on the Staged pane" is representable, means nothing, and has to +be kept coherent by every path that touches either. `staging_role` already has to reconcile them. + +The obvious alternative — deleting zoom outright and always rendering `Split` — was considered and +rejected. `effective_zoom` would still pick correctly in every case with no user input, but a file +with *both* staged and unstaged hunks would be permanently pinned at half a body each, minus two +caption rows, with no escape. On a short terminal that is single-digit content rows per pane, and +partially-staged files are common when staging hunk-by-hunk while reviewing. + +A related cut is *not* available and is recorded here so it is not re-proposed. +`DiffState::from_committed` builds a committed changeset with **both sub-models empty**: nothing in +its index differs from HEAD, nothing in its worktree differs from its index. `Role::Combined` is +therefore the only non-empty role for every committed changeset — every changeset in a stack but +the uncommitted layer, and all of `git workon review | | pr-123`. It is also the +forced role for binary files, which cannot be staged. Combined is the crate's reading view; the two +sub-roles exist because the index is writable. `Role::Combined` stays. + +**Amended 2026-08-31.** I renamed `Role::Combined` to `Role::Whole`. The mechanism above is +unchanged; only the name was wrong. "Combined" reads as a selectable view, and after this ADR +there is no such view left to select (`Zoom::Combined` is gone). The role survives only as +the diff `effective_zoom` falls back to when there is no staged/unstaged split to show (a +committed changeset, or a binary file). `Whole` names what the role actually is: the whole +change, `HEAD` ↔ worktree for the uncommitted layer, or `base` ↔ `head` for a committed +changeset, with no split. I considered `ReadOnly` and rejected it. It names a property of the +role (verbs refuse there), not which trees it diffs, and the role is also a valid content +source for yank, so "read-only" undersells it. + +## Decision + +**1. Delete the `Zoom` enum.** All four variants, the `cycle_zoom` cycle, and `set_zoom`. + +**2. The diff pane's requested state becomes two orthogonal fields.** `split_focus: SplitPane`, +which already exists, and a new `maximized: bool`. Maximize means "give the focused pane the whole +body." The state that meant nothing — focus and zoom naming different panes — becomes +unrepresentable. + +**3. `effective_zoom` takes the new inputs and narrows.** Its whole truth table: + +| Condition | Result | +|---|---| +| `!can_stage` | `Single(Whole)` | +| both sub-diffs, `maximized` | `Single(focus.role())` | +| both sub-diffs, not maximized | `Split` | +| unstaged only | `Single(Unstaged)` | +| staged only | `Single(Staged)` | +| neither | `Single(Whole)` | + +Maximize applies only where the result would otherwise be `Split`. Everywhere else the pane already +fills the body, so the flag is inert rather than special-cased. + +**4. Pressing the maximize key when the gate is not returning `Split` is a silent no-op.** Not a +refusal. The user asked for a full-height pane and already has one. The committed-changeset case +keeps an informational notice, reworded — a committed changeset is combined-only, which is worth +saying once rather than leaving the key apparently dead. + +**5. `reset_panes` preserves `split_focus` when `maximized` is set.** It currently resets focus to +`SplitPane::Unstaged` on every file open. Under maximize, focus *is* the view, so resetting it +would silently switch which role you are reading when you navigate. Today `Zoom::Staged` persists +across file navigation; preserving focus under maximize is what keeps that behavior. + +**6. `maximized` persists across file navigation and refresh**, matching the `Zoom` behavior it +replaces. There are existing tests asserting zoom survives both; they carry over to the new field +rather than being deleted. + +**7. Delete `attribute.rs` and its render integration.** With `Zoom::Combined` gone, +`Role::Whole` is unreachable on an uncommitted changeset, and `combined_attribution` already +returns `None` for non-whole roles and for committed changesets. Every surviving whole-role render +is already `AttributionMode::Plain`. Remove the module, its `pub mod` line, +`combined_attribution`, and `AttributionMode::Attributed` — which also drops that enum's lifetime +parameter, leaving `Plain` and `StagedUniform`. + +**8. Remove `workon.review.diff.zoom`.** No replacement key. Maximize is a transient view action, +not a startup preference, and `split_focus` already has no config surface. Removing the read makes +the key unclaimed, so `ReviewConfig::unknown_key_warnings` warns on it for free, with its +Levenshtein "did you mean" pointing at the neighbouring `diff.*` keys. Add no compatibility alias: +the crate is unreleased and `publish = false`, so there is no configuration in the wild, and an +alias would keep a dead concept in the user-facing vocabulary. + +**9. Rename the keymap action `cycle-zoom` to `toggle-maximize`,** keeping the `Z` binding. The +keymap already warns on unknown action names, so a user config naming `cycle-zoom` degrades with a +warning rather than silently doing nothing. Preserve the `zM`/`zR` collision constraint that moved +this action off bare `z` in the first place. + +**10. Reword `notify_unstageable_refusal`'s non-committed branch.** It reads +`"{verb} in the unstaged/staged pane — cycle zoom ({key})"`. After this change, binary files are +its only caller, and that advice is wrong for them: `effective_zoom` short-circuits on `!can_stage` +before it looks at anything else, so no key press moves them out of `Role::Whole`. State that +the file is not stageable. This is a pre-existing defect the change exposes rather than creates — +the branch is currently shared with the ordinary combined-zoom case, where the advice is correct, +which masks it. + +## Consequences + +The diff pane's requested state goes from a four-variant enum plus an independent focus field to +one bool plus that focus field, and one inconsistent combination stops existing. `effective_zoom` +loses an input dimension and gains a smaller table. + +The crate loses a pure module, a config key, an enum variant with its lifetime, and the asymmetric +attribution invariant — a combined *deletion* checked against the staged diff's old side, an +*addition* against the unstaged diff's new side. That asymmetry is the most easily-broken thing in +the crate and served only the view being removed. + +Reviewing a dirty worktree no longer offers a fused pane showing staged and unstaged changes +together, color-coded by staged-ness. The split shows both as separate navigable panes, and +maximize gives either one the full body. + +Committed-changeset review is unaffected. Worth stating plainly, because the change reads like it +should affect it: `git workon review `, ranges, PRs, and stack navigation render exclusively +through `Role::Whole` and are untouched. + +If a fused uncommitted view is wanted later, `attribute.rs` and its tests are recoverable from +history at this ADR's commit, and the asymmetry rationale is preserved in its module header. + +## Gotchas + +- **Do not follow `Role::Whole` into the model.** The likeliest way to break this is to read + "remove combined" as reaching `DiffState.files` — which *is* the whole-diff model, and is the + file-list spine: `role_change(idx, Role::Whole)` returns `diff.files[idx]`, and + `unstaged_idx`/`staged_idx` are offsets into it. +- **`effective_zoom` keeps every downgrade to `Role::Whole`.** Binary files and the + no-sub-diff case still land there. Only the `Zoom::Combined` arm goes. +- **Around twenty tests reach a view state by setting zoom** (`set_zoom(Zoom::Combined)`, + `app.zoom = Zoom::…`, across `app.rs` and `render.rs`). This is not a mechanical rewrite. Each + needs a decision: a test of combined *rendering* re-points at a committed changeset or a binary + file; a test of *zoom mechanics* either moves to `maximized` or goes. Tests asserting the state + survives navigation or refresh must move rather than be deleted — that behavior is still real. +- **`cycle_zoom_walks_the_four_states_and_persists_across_file_nav` splits in two.** The cycle half + goes; the persistence half becomes a `maximized` test. Add a case the old test could not express: + maximized on the staged pane, navigate to another file, assert focus and maximize both survive + (the `reset_panes` focus-preservation decision above). +- **The attribution render test guards a real bug.** One test pins that + `Attribution::build(None, None)` would otherwise miscolor every Add cell as already-staged. Read + it before deleting and confirm the failure mode cannot reappear through `AttributionMode::Plain`. +- **`config.rs`'s module header documents `zoom = combined`** at line 45. +- **`zoom_key_label` and its plumbing** (`main.rs::plumb_zoom_hint_and_warnings`, the `App` field, + `set_zoom_key_label`) exist to put the live key in the refusal message. After decision 10 that + message no longer names a key, so the whole path may be removable — check whether anything else + consumes it before deleting. + +## Verification + +- `~/.claude/bin/cargo-gate test` — full workspace green. The gate is the pre-commit bar. +- `~/.claude/bin/cargo-gate clippy` — `-D warnings`, all targets, all features. +- Manual: partially stage a file so it has both staged and unstaged hunks. Confirm the split, then + `w` to the staged pane, then `Z` — the staged pane fills the body. `Z` again restores the split. +- Manual: while maximized on the staged pane, navigate to another file and back. Confirm both the + maximize and the staged focus survive. +- Manual: on a file with only unstaged changes, press `Z`. Confirm nothing happens and no error + appears. +- Manual: `git workon review `. Confirm it renders, and that `Z` reports the + committed-changeset notice rather than appearing dead. +- Manual: navigate to a binary file on a dirty worktree and press a staging key. Confirm the + refusal names non-stageability and does not advise pressing anything. +- `git config workon.review.diff.zoom combined` then launch — confirm one unknown-key warning and a + normal start. diff --git a/docs/plans/review-any-source.md b/docs/plans/review-any-source.md index df7e4e85..e4fc7bbc 100644 --- a/docs/plans/review-any-source.md +++ b/docs/plans/review-any-source.md @@ -1,4 +1,4 @@ -# Plan — Review Any Source (M7) +# Plan — Review Any Source Design locked 2026-07-09. Decisions live in **[ADR-036](../adr/036-review-source-grammar.md)** (source grammar, per-shape resolution, error posture, completion scope, the @@ -8,7 +8,7 @@ rationale. Glossary terms ("Review source", "Changeset span", "Uncommitted layer [CONTEXT.md](../../CONTEXT.md). Goal: `git workon review []` reviews *anything* — stack, uncommitted, ref, range, -PR — not just the auto-detected state. Read-only for committed sources (M5 semantics); +PR — not just the auto-detected state. Read-only for committed sources (stack-and-outline semantics); no-arg auto-detect behavior is byte-identical to today. ## Scope (five tracks) @@ -27,44 +27,45 @@ no-arg auto-detect behavior is byte-identical to today. PR title carried through. No worktree is created. 5. **Completion** — review-binary completer offers keywords + local branches + tags (and the RHS after `..`/`...`); git-workon's completer sub-delegates post-subcommand - words to `COMPLETE= git-workon-review` (the M6-deferred shell-out). + words to `COMPLETE= git-workon-review` (the CLI-integration-deferred shell-out). ## Changeset partition (Graphite stack) Linear stack — each unit extends the classifier the previous one introduced. Base: -the current M3–M6.5 tower tip (`uc-roadmap-reprioritize`/`uc-pty-smoke`), or `main` once +the current initial-renderer-through-usability-pass tower tip (`uc-roadmap-reprioritize`/`uc-pty-smoke`), or `main` once the tower lands. Each unit is land-alone (green + valuable by itself) and standalone-review (~≤400 non-mechanical lines). ``` - └─ m7-span-rename CS1 ── ChangesetSource → ChangesetSpan (mechanical) - └─ m7-source-keywords CS2 ── Source enum, positional arg, stack/uncommitted keywords - └─ m7-source-revs CS3 ── dispatch + ranges (grammar complete) - └─ m7-source-pr CS4 ── PR references via pr.rs - └─ m7-complete CS5 ── source completion + git-workon sub-delegation + └─ m7-span-rename ── ChangesetSource → ChangesetSpan (mechanical) + └─ m7-source-keywords ── Source enum, positional arg, stack/uncommitted keywords + └─ m7-source-revs ── dispatch + ranges (grammar complete) + └─ m7-source-pr ── PR references via pr.rs + └─ m7-complete ── source completion + git-workon sub-delegation ``` -Interim behavior is honest at every cut: before CS3, a ref/range argument fails the -keyword match and errors pre-TUI as an unresolvable source; before CS4, `pr-123` falls +Interim behavior is honest at every cut: before the ``-and-range-resolution +changeset lands, a ref/range argument fails the keyword match and errors pre-TUI as an +unresolvable source; before the PR-reference-resolution changeset lands, `pr-123` falls through to the ref arm and errors the same way (named, hinted). ## Per-changeset detail -### CS1 — `m7-span-rename` (refactor, lib + review) +### The ChangesetSource→ChangesetSpan rename (`m7-span-rename`, refactor, lib + review) - `refactor(lib): rename ChangesetSource to ChangesetSpan`. Type, `Changeset.source` field → `Changeset.span`, doc comments, all use sites in `acquire.rs`/`app.rs`/tests. - Purely mechanical; no behavior change. Verify: full workspace green, `grep -rn ChangesetSource` returns nothing. -### CS2 — `m7-source-keywords` (review crate + one lib seam) +### The stack/uncommitted source keywords (`m7-source-keywords`, review crate + one lib seam) - New `source.rs` in the review lib: `Source` enum (`Auto | Stack | Uncommitted | Ref(String) | Range{..} | Pr(PullRequest)`) with - `Source::classify(&str)` implementing the ADR precedence. In CS2 the classifier ships - with keyword + fallback-to-`Ref` arms only; `Ref` resolution errors as unresolvable - (real resolution is CS3). Classification is pure → unit-test exhaustively (keyword + `Source::classify(&str)` implementing the ADR precedence. In this changeset the classifier + ships with keyword + fallback-to-`Ref` arms only; `Ref` resolution errors as unresolvable + (real resolution is the ``-and-range-resolution changeset). Classification is pure → unit-test exhaustively (keyword exactness: `Stack` ≠ `stack` keyword? No — exact bare match is case-sensitive `stack`; `refs/heads/stack` classifies as `Ref`). - `Cli` gains `Option` positional `[SOURCE]`; `main.rs` routes @@ -77,17 +78,18 @@ through to the ref arm and errors the same way (named, hinted). - **Lib seam**: `assemble_changesets` must be able to omit the uncommitted layer (ADR-036: layer only when focused on real HEAD). Prefer an explicit parameter over a post-filter — a post-filter must also repair the `current` flag, which is subtle. - CS2 introduces the seam (keywords always run with the layer *on*, since `stack` - reviews HEAD's stack); CS3 is the first caller that turns it off. + This changeset introduces the seam (keywords always run with the layer *on*, since `stack` + reviews HEAD's stack); the ``-and-range-resolution changeset is the first caller that + turns it off. - New error variants in review `error.rs` per ADR-008 — **load `/docs errors` first**. - Verify: fixture tests for both keyword resolutions in Graphite and plain-git repos (sqlite + legacy metadata modes), error cases asserted with `NO_COLOR=1`. -### CS3 — `m7-source-revs` (review crate + acquire) +### `` and range resolution (`m7-source-revs`, review crate + acquire) - `Ref` resolution, dispatched on shape (ADR-036): Graphite-tracked branch → `assemble_changesets` focused there, uncommitted layer ON iff the ref is the actual - `HEAD` branch (first user of the CS2 lib seam); untracked branch → one committed + `HEAD` branch (first user of the stack/uncommitted-source-keywords lib seam); untracked branch → one committed changeset, base = merge-base(upstream, else trunk, else error); commit-ish → `parent..ref` (root commit: empty-tree base). - `Range` resolution: split on `...` first, then `..`; empty side → `HEAD`; rev-parse @@ -98,7 +100,7 @@ through to the ref arm and errors the same way (named, hinted). `review ` == auto-detect output (layer present); reviewing a non-HEAD tracked branch on a dirty tree asserts NO uncommitted layer and correct `current`. -### CS4 — `m7-source-pr` (review crate, reuses lib `pr.rs`) +### PR-reference resolution (`m7-source-pr`, review crate, reuses lib `pr.rs`) - Classifier gains the PR arm at top precedence (`parse_pr_reference`; also accept `pr-123` if the lib parser doesn't already — check first, extend the *lib parser* @@ -111,15 +113,16 @@ through to the ref arm and errors the same way (named, hinted). the gh-network path itself is exercised manually — record the manual check in the changeset description). -### CS5 — `m7-complete` (review crate + git-workon completer) +### Source completion and sub-delegation (`m7-complete`, review crate + git-workon completer) - Review-binary completer: keywords + local branch names + tag names via git2 ref enumeration; when the current word contains `..`/`...`, complete the RHS ref the same way. Offline only; no PR numbers. - git-workon side: the dynamic completer's external-subcommand arm shells out `COMPLETE= git-workon-review -- ` for post-subcommand words - (M6 CS3 left this seam documented; remember `_CLAP_COMPLETE_INDEX`). -- Verify: completion integration tests per M6's pattern (`COMPLETE=` env protocol), + (the CLI-integration work's external-subcommand-completion-enumeration changeset left + this seam documented; remember `_CLAP_COMPLETE_INDEX`). +- Verify: completion integration tests per the CLI-integration work's pattern (`COMPLETE=` env protocol), asserting keyword + ref candidates and the delegation path. ## Traps / notes for the implementer @@ -127,8 +130,8 @@ through to the ref arm and errors the same way (named, hinted). - **Load `/docs testing` before any tests; `/docs errors` before error variants.** - `FORCE_COLOR=3` is set in this environment — output-asserting tests pin `NO_COLOR=1`. - Verify TUI behavior by instrumenting, never by grepping ratatui frames. -- The Git-inference arm (`assemble_git`) is lib-complete and lib-tested; CS2 only wires - it. Don't reimplement. +- The Git-inference arm (`assemble_git`) is lib-complete and lib-tested; the + stack/uncommitted-source-keywords changeset only wires it. Don't reimplement. - `resolve_changesets`'s doc comment explains why auto-detect must NOT route plain-git repos to `StackModel::Git` — that reasoning stays true; only the explicit `stack` keyword takes the Git arm. @@ -146,7 +149,7 @@ cargo fmt --all -- --check cargo run -p git-workon-review -- # manual: each source shape renders ``` -## Acceptance (RFC M7) +## Acceptance (RFC source-selector work) `git workon review ` / `` / `pr-123` renders the right changeset(s); `git workon review ` completes sources. diff --git a/docs/plans/review-usability-pass.md b/docs/plans/review-usability-pass.md index 6508aa74..ad5883c0 100644 --- a/docs/plans/review-usability-pass.md +++ b/docs/plans/review-usability-pass.md @@ -1,11 +1,11 @@ -# Plan — Review TUI Everyday-Usability Pass (M6.5) +# Plan — Review TUI Everyday-Usability Pass Design locked 2026-07-07. Decisions live in **[ADR-034](../adr/034-review-git-native-config-schema.md)** (config schema + keymap) and **[ADR-035](../adr/035-review-theming-base16-hybrid.md)** (theming). This doc is the *execution* plan: what lands, in what order, how each unit is verified. Read both ADRs before implementing — this plan does not restate their rationale. -Comments (M7) are deprioritized behind this pass. Goal: make the review TUI usable for +Comments (now part of the agent-loop work) are deprioritized behind this pass. Goal: make the review TUI usable for everyday review work — configurable keybindings, discoverable help, real theming. ## Scope (four tracks) @@ -25,25 +25,29 @@ everyday review work — configurable keybindings, discoverable help, real themi ## Changeset partition (Graphite stack) -Two independent tracks fan out from the shared config reader (CS1), plus view-config off CS1. +Two independent tracks fan out from the shared config reader (the git-config reader), plus +view-config off it. Each unit is land-alone (green + valuable on `main` by itself) and standalone-review. ``` main - └─ uc-review-config CS1 ── shared git-config reader - ├─ uc-keymap CS2 ── configurable per-view keymaps (keybinding track) - │ └─ uc-help CS3 ── footer + ? overlay - ├─ uc-theme-base16 CS4 ── Theme primitive + render-time resolution (dark only, no visible change) - │ └─ uc-theme-light CS5 ── curated light + theme=dark|light - │ └─ uc-theme-auto CS6 ── terminal-derivation probe + theme=auto default - └─ uc-view-config CS7 ── outline.width/mode, diff.layout/zoom + └─ uc-review-config ── shared git-config reader + ├─ uc-keymap ── configurable per-view keymaps (keybinding track) + │ └─ uc-help ── footer + ? overlay + ├─ uc-theme-base16 ── Theme primitive + render-time resolution (dark only, no visible change) + │ └─ uc-theme-light ── curated light + theme=dark|light + │ └─ uc-theme-auto ── terminal-derivation probe + theme=auto default + └─ uc-view-config ── outline.width/mode, diff.layout/zoom ``` -Order of landing: CS1 → (CS2 → CS3) and (CS4 → CS5 → CS6) and CS7. The keymap and theming -subtrees are independent after CS1; land in either interleaving. Main-thread diff-read each +Order of landing: the git-config reader → (configurable per-view keymaps → the help footer and +`?` overlay) and (the base16 palette primitive → the curated light scheme → the +terminal-derivation probe for `theme=auto`) and the view-config settings. The keymap and +theming subtrees are independent after the git-config reader; land in either interleaving. +Main-thread diff-read each before the next lands (per the working style). -### CS1 — `ReviewConfig` reader +### The git-config reader (`ReviewConfig`) - **Decision:** the review binary reads git config for the first time. Mirror `git-workon-lib/src/config.rs`'s `WorkonConfig` pattern: read via `repo.config()` (the `App` already owns a `Repository` — see `app.rs`). New module `git-workon-review/src/config.rs`. @@ -53,7 +57,7 @@ before the next lands (per the working style). - Verify: unit tests reading `workon.review.*` from a `FixtureBuilder` repo (both a set and an unset/default case). Load `/docs testing` first; use FixtureBuilder + predicates. -### CS2 — Action registry + configurable keymaps +### Configurable per-view keymaps (action registry) - **Decision:** ADR-034. Replace the hardcoded `map_key` match (`tui.rs`) with a registry-driven dispatch. - Build the **action registry**: one table `action → (default keys, human description, view ∈ @@ -62,7 +66,7 @@ before the next lands (per the working style). - **Token-grammar parser** (ADR-034): reserved symbolic names (incl. `space`, `tab`, `enter`, `esc`, arrows, `backtab`, `f1`–`f12`), modifier prefixes (`ctrl-`/`alt-`/`shift-`), literal chars, chords (`]f`). Reserved-word-wins disambiguation. -- **Load + invert:** read every `workon.review.*.bind.*` var (via CS1), split values into key +- **Load + invert:** read every `workon.review.*.bind.*` var (via the git-config reader), split values into key tokens, build per-view `key → action` maps. A git entry overrides that action's default (native single-value precedence — no custom layering). Empty value = unbind. - **Validation + collisions:** unknown `bind.` → footer warning (action set is @@ -74,7 +78,7 @@ before the next lands (per the working style). action, a collision); a dispatch test asserting a rebind takes effect. `map_key`'s existing behavior tests must still pass (defaults unchanged). -### CS3 — Help surface +### The help footer and `?` overlay - **Decision:** persistent curated per-view footer + `?` overlay targeting the focused view. - **Footer:** always-visible one line of ~5–7 **hand-curated** keys for the focused context (diff vs outline), rendered from the resolved map + registry descriptions. Updates @@ -89,7 +93,7 @@ before the next lands (per the working style). instrument via a log-file + expect harness, NOT ratatui frame grepping (see the TUI-dogfood memory). -### CS4 — base16 `Theme` primitive + render-time resolution +### The base16 palette primitive (`Theme` + render-time resolution) - **Decision:** ADR-035. Largest mechanical unit; **behavior-preserving** (dark stays pixel-identical), so land-alone with no user-visible change. - Introduce `struct Base16 { base00..base0F }` / `Theme`. Re-express the current `render.rs` @@ -105,13 +109,13 @@ before the next lands (per the working style). fixed test `Theme` (dark) — same asserted colors. Full workspace green. This is the regression gate that the refactor changed nothing. -### CS5 — Curated light scheme + `theme = dark|light` +### The curated light scheme (`theme = dark|light`) - Add the **light** base16 instance (paste a published base16 light scheme's 16 hexes — do - NOT hand-invent; ADR-035). Wire `workon.review.theme` (via CS1) to select dark/light; + NOT hand-invent; ADR-035). Wire `workon.review.theme` (via the git-config reader) to select dark/light; derived tints recompute for light automatically. - Verify: `theme=light` selects the light instance; tints derive; a render test at light. -### CS6 — `theme = auto` terminal-derivation probe +### The terminal-derivation probe for `theme=auto` - **Decision:** ADR-035. The single most terminal-fragile unit — isolated on purpose. - OSC probe on the controlling `/dev/tty` (TUI already renders there — `tui.rs`) at startup, raw mode, short timeout: `OSC 4;n;?` (n=0–15) + `OSC 10/11`. Populate slots from real RGB. @@ -122,10 +126,10 @@ before the next lands (per the working style). - Verify: probe parses a synthetic OSC reply into slots; timeout path falls back to curated (no hang) — drive with a fake tty/reader, do not depend on the test terminal answering. -### CS7 — View-config settings -- Read `workon.review.outline.width|mode` and `workon.review.diff.layout|zoom` (via CS1), - current hardcoded values as defaults. `outline.width` also addresses M5's deferred - narrow-terminal papercut. +### The view-config settings +- Read `workon.review.outline.width|mode` and `workon.review.diff.layout|zoom` (via the + git-config reader), current hardcoded values as defaults. `outline.width` also addresses + the stack-and-outline work's deferred narrow-terminal papercut. - Verify: each setting overrides its default from a fixture config; unset = current default. ## Cross-cutting notes / gotchas @@ -143,5 +147,6 @@ before the next lands (per the working style). ## Deferred (explicitly not this pass) - `theme = ` / user-supplied base16 scheme files (the "user-configurable colors" tier). Additive later — the slot *source* is pluggable behind render-time resolution (ADR-035). -- Post-subcommand completion delegation (M6 note), git-inference stack model, ref-range - sources (M5), and all of M7 comments onward. +- Post-subcommand completion delegation (the CLI-integration work's note), git-inference + stack model, ref-range sources (the stack-and-outline work), and all of the agent-loop + work onward. diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 97cbc06b..97fdd672 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -25,7 +25,7 @@ It is the productization of a working Neovim prototype (`~/.config/nvim/lua/app/ | Fixture | `git-workon-fixture` is the test substrate for both crates. Extend it: SQLite-format graphite metadata mode (the sqlite read path is currently fixture-untested — builder only writes legacy refs blobs) and index-state builders (staged/unstaged/untracked combos). | | Highlighting | tree-sitter (tree-sitter-highlight), syntect as long-tail fallback. Measured: ts ~0.01ms/line vs syntect ~0.19ms/line, and better output. Grammar set + gotchas are in the spike. | | View model | Full parity with the prototype's four zoom states (split/combined/unstaged/staged + attributed rendering). If v1 must shrink, cut zoom states — never the comments loop. | -| v1 sources | uncommitted, stack, ref/range, **PR** — all folded into **M7 "review any source"** (PR was deferred; now first, via git-workon-lib's `pr.rs`). | +| v1 sources | uncommitted, stack, ref/range, **PR** — all folded into **the source-selector work ("review any source")** (PR was deferred; now first, via git-workon-lib's `pr.rs`). | | Comments | MCP: on-disk comment store (`.review/` JSON or sqlite) + `git-workon-review mcp` stdio subcommand serving get/resolve tools; TUI watches the store. Degrades to a plain file convention for non-MCP harnesses. | | Edit flow | Embedded: `nvim --server $NVIM --remote + `. Standalone: `$EDITOR`. File watcher refreshes on save. | | Completions | Full clap_complete (unstable-dynamic, already a workspace dep) on the direct binary. Work item: git-workon's dynamic completer enumerates `git-workon-*` on PATH and delegates post-subcommand completion via `COMPLETE= git-workon-review -- `. Git-level shims: on demand only. | @@ -40,15 +40,15 @@ It is the productization of a working Neovim prototype (`~/.config/nvim/lua/app/ Hard-won semantics from the prototype, all of which caused real bugs. Each becomes a test before its feature is implemented: -1. **Patch direction rules**: synthesizing a partial patch (line-precise staging) has direction-dependent drop rules. Forward apply (stage): dropped adds omitted, dropped dels → context. Reverse apply (unstage `--cached --reverse`, discard `--reverse`): dropped adds → context, dropped dels omitted — git rejects any partial selection otherwise. Round-trip test both directions + a tripwire asserting forward rules do NOT reverse-apply. -2. **No-newline EOF corruption (silent!)**: a dropped del converted to context carrying the `\ No newline at end of file` marker, followed by a kept add, is ACCEPTED by git apply (exit 0) which concatenates the add onto the no-newline line — corrupt blob, no error. Fix: splice into del+re-add form when kept lines follow. Assert the exact blob bytes. -3. **Whole-file ops for A/D/U statuses**: hunk-level patches can't express creations/deletions (untracked hunk-stage errors; deleted-file hunk-stage stages an EMPTY BLOB). Fall back to file-level ops; line-selection on these REFUSES with a notify. -4. **Staging queue**: FIFO, op stays queued while in flight (remove-before-run double-runs); ops resolve direction from the LIVE index inside the queued op, never from a snapshot (stale-snapshot toggles silently no-op); retry once on `index.lock` contention (~100ms); pcall/catch around ops (a sync throw deadlocks the queue). -5. **Refresh generation/livelock**: refreshes carry a generation seq; a superseded completion must re-snapshot the index signature BEFORE the supersede check returns, or its own diff's stat-cache rewrite echoes into the index watcher and livelocks refresh forever under staging storms. -6. **git2 re-verification**: all of the above were validated against git CLI. Re-run the round-trip corpus against libgit2's apply/index. Divergence → shell out to `git apply` for writes (reads stay git2). -7. **Metadata revisions are snapshots, not refs** (found dogfooding the prototype on this repo, 2026-07-05): graphite's `branch_revision` updates only when gt runs — commits made with plain git (i.e. any commit made outside gt) leave it stale. The prototype used it as the changeset head, so a freshly-committed branch rendered an EMPTY changeset (`head_rev == parent_rev ==` fork point) while still appearing in the stack. Changeset head must resolve the live ref (`refs/heads/`); `parentBranchRevision` remains the correct BASE (diff-as-authored + needs-restack input) — do not "fix" it to live trunk. Related: the prototype swallows per-changeset diff errors into an empty file list — a failed diff must be distinguishable from a genuinely empty changeset. Test: fixture branch tracked in metadata, then commits added with plain git; assert the changeset spans fork..live-head and that a bad ref surfaces an error, not an empty changeset. +1. **Direction-dependent drop rules**: synthesizing a partial patch (line-precise staging) has direction-dependent drop rules. Forward apply (stage): dropped adds omitted, dropped dels → context. Reverse apply (unstage `--cached --reverse`, discard `--reverse`): dropped adds → context, dropped dels omitted — git rejects any partial selection otherwise. Round-trip test both directions + a tripwire asserting forward rules do NOT reverse-apply. +2. **No-newline-at-EOF splice (silent!)**: a dropped del converted to context carrying the `\ No newline at end of file` marker, followed by a kept add, is ACCEPTED by git apply (exit 0) which concatenates the add onto the no-newline line — corrupt blob, no error. Fix: splice into del+re-add form when kept lines follow. Assert the exact blob bytes. +3. **Whole-file-ops fallback (A/D/U statuses)**: hunk-level patches can't express creations/deletions (untracked hunk-stage errors; deleted-file hunk-stage stages an EMPTY BLOB). Fall back to file-level ops; line-selection on these REFUSES with a notify. +4. **Live-index staging queue**: FIFO, op stays queued while in flight (remove-before-run double-runs); ops resolve direction from the LIVE index inside the queued op, never from a snapshot (stale-snapshot toggles silently no-op); retry once on `index.lock` contention (~100ms); pcall/catch around ops (a sync throw deadlocks the queue). +5. **Refresh echo suppression**: refreshes carry a generation seq; a superseded completion must re-snapshot the index signature BEFORE the supersede check returns, or its own diff's stat-cache rewrite echoes into the index watcher and livelocks refresh forever under staging storms. +6. **git2-vs-CLI round-trip verdict**: all of the above were validated against git CLI. Re-run the round-trip corpus against libgit2's apply/index. Divergence → shell out to `git apply` for writes (reads stay git2). +7. **Stale-metadata head** (metadata revisions are snapshots, not refs; found dogfooding the prototype on this repo, 2026-07-05): graphite's `branch_revision` updates only when gt runs — commits made with plain git (i.e. any commit made outside gt) leave it stale. The prototype used it as the changeset head, so a freshly-committed branch rendered an EMPTY changeset (`head_rev == parent_rev ==` fork point) while still appearing in the stack. Changeset head must resolve the live ref (`refs/heads/`); `parentBranchRevision` remains the correct BASE (diff-as-authored + needs-restack input) — do not "fix" it to live trunk. Related: the prototype swallows per-changeset diff errors into an empty file list — a failed diff must be distinguishable from a genuinely empty changeset. Test: fixture branch tracked in metadata, then commits added with plain git; assert the changeset spans fork..live-head and that a bad ref surfaces an error, not an empty changeset. -## M2 verdict (git2 vs CLI apply) +## git2 vs CLI apply verdict The round-trip corpus (`git-workon-review/tests/roundtrip_corpus.rs`) drives every write-path scenario class from the trap corpus above through `ops.rs`'s entry points against both backends. @@ -61,7 +61,7 @@ Measured result (updated after the 2026-07-06 stack review, see below): **0 dive | Partial stage (adds-only/dels-only/mixed) | pass | | Partial unstage / partial discard | pass | | EOFNL per verb (whole-hunk) | pass | -| EOFNL trap-2 splice (partial stage) | pass | +| EOFNL no-newline-at-EOF splice (partial stage) | pass | | Multi-hunk file, one hunk staged | pass | | Space-in-filename header handling | pass | | Rename (read-side, `diff_committed`) | pass | @@ -82,14 +82,14 @@ fails with the specific scenario and divergence class, and the fix is to update `KNOWN_DIVERGENCES` (or flip the default writer) with that evidence in hand, not to relitigate this section from memory. -Two tripwire findings from earlier M2 changesets are now pinned as permanent regression tests, +Two tripwire findings from earlier diff-model-and-patch-synthesis changesets are now pinned as permanent regression tests, not just corpus coverage: -- **Trap 3 (empty-blob deletion staging)**: `naive_hunk_stage_of_deletion_stages_empty_blob` in - `git-workon-review/tests/file_ops.rs` — a naive whole-hunk stage of a deletion is accepted by +- **Whole-file-ops fallback (empty-blob deletion staging)**: `naive_hunk_stage_of_deletion_stages_empty_blob` in + `git-workon-review/tests/suite/file_ops.rs` — a naive whole-hunk stage of a deletion is accepted by `git apply --cached` but stages an empty blob instead of removing the index entry. -- **Trap 2 (EOFNL silent concatenation)**: `naive_unspliced_eofnl_patch_silently_corrupts_the_index` - in `git-workon-review/tests/line_synthesis.rs` — a dropped deletion converted to context while +- **No-newline-at-EOF splice (silent concatenation)**: `naive_unspliced_eofnl_patch_silently_corrupts_the_index` + in `git-workon-review/tests/suite/line_synthesis.rs` — a dropped deletion converted to context while still carrying its `\ No newline at end of file` marker, followed by a kept line, is accepted by `git apply` (exit 0) but silently concatenates the two lines into one corrupt line. @@ -97,7 +97,7 @@ not just corpus coverage: The "0 divergences across 22 scenarios" claim above predates a high-effort stack review that found two more divergence classes the original corpus missed. Both were fixed in place (in the -M2 changeset that introduced them) and are now pinned in the corpus/regression suite, so the +diff-model-and-patch-synthesis changeset that introduced them) and are now pinned in the corpus/regression suite, so the verdict — `Git2Applier` as the default write path — **stands**; these are corrections to the evidence, not to the conclusion. @@ -114,46 +114,54 @@ evidence, not to the conclusion. deletion carrying `missing_newline: true`, followed by a dropped addition converted to context (`base == New`'s drop rule), produced a hunk where the two backends actually DISAGREED rather than merely diverging in end state: `CliApplier` accepted it and silently concatenated the - next line onto the no-newline deletion (the same class of corruption as the original trap-2 - finding); `Git2Applier` rejected the patch outright (`invalid patch hunk`). In this instance + next line onto the no-newline deletion (the same class of corruption as the original + no-newline-at-EOF splice finding); `Git2Applier` rejected the patch outright (`invalid patch hunk`). In this instance git2 was the SAFE side — refusing a malformed patch is preferable to silently corrupting a file — which is itself evidence for, not against, the `Git2Applier`-default verdict. Fixed by - extending the trap-2 splice (`splice_eofnl_context_lines`) to also rewrite a kept deletion's + extending the no-newline-at-EOF splice (`splice_eofnl_context_lines`) to also rewrite a kept deletion's own bytes (real trailing `\n`, marker dropped) when a later emitted line is context. Pinned by - `kept_eofnl_deletion_needs_splice_under_base_new` in `git-workon-review/tests/line_synthesis.rs` + `kept_eofnl_deletion_needs_splice_under_base_new` in `git-workon-review/tests/suite/line_synthesis.rs` (covers both backends via `Discard`); not duplicated into the corpus since that test already exercises the identical fixture/selection/direction against both appliers end-to-end. ## Milestones -- **M0 — workspace plumbing.** New member crate `git-workon-review` (lib+bin, clap, error model matching workspace: thiserror+miette). Toolchain bump (ratatui/tree-sitter won't meet 1.68.2; resolved: workspace-wide `rust-version = 1.88` — no crate had ever inherited the old value, so there was no lib MSRV to preserve). Lib hygiene (drop unused dialoguer/env_logger). CI: tree-sitter C builds. Release posture per [ADR-033](../adr/033-review-crate-workspace-placement.md): `publish = false` keeps the crate out of release-plz and cargo-dist entirely; release-plz wiring is deliberately deferred to the M3 flip — do NOT add a release-plz.toml entry in M0. Acceptance: `cargo build --workspace` green, empty `git-workon-review` binary runs and prints help. -- **M1 — fixture extensions + lib stack capabilities (test-first).** Fixture: sqlite metadata mode (also finally exercises the lib's primary read path), index-state builders. Lib: `parentBranchRevision` read (both formats) + needs-restack; git-inference StackModel; changeset assembly API (`Vec {branch, base_ref, head_ref, title, current, needs_restack}` + uncommitted layer). Acceptance: existing lib tests green + new capabilities spec'd against fixtures in both metadata formats. -- **M2 — trap corpus port.** Diff parser + patch synthesis in the review lib, the six trap items as tests, git2-vs-CLI verdict rendered (and the write-path decision recorded here). Acceptance: round-trip corpus green against real repos. — DONE (2026-07-06): corpus green on both backends; verdict recorded above. -- **M3 — renderer + uncommitted source.** Port spike modules; wire changeset → parsed diff → SBS/inline render; file nav; the uncommitted source end-to-end. Acceptance: dogfood-able read-only review of a dirty worktree. — DONE (2026-07-06): combined-zoom read-only review with SBS + inline layouts, collapsed context gaps, word-diff emphasis, tree-sitter highlighting (spike's 8 grammars; syntect deferred), file/hunk nav; dogfooded against a dirty worktree. Port note: the spike's `compose_segments` had a latent first-match span-precedence bug that silently dropped word-level emphasis — fixed here (reverse-order lookup), pinned by a three-way bg test in `render.rs`. -- **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. Design locked 2026-07-06 (plan artifact `iron-lattice`): (1) staging = prototype parity — verbs act only in unstaged/staged panes, combined refuses, direction = pane role (combined-native toggle deferred); (2) cursor-primary nav in all views, scroll derived; (3) full 4-state zoom (`split→combined→unstaged→staged`) with per-file `_gate` downgrade and stacked split panes (per-pane cursor, `w` focus), no collapse debounce; (4) runtime stays sync — poll `IndexSignature` on Tick, synchronous re-diff (no threads/notify dep); (5) queue enqueue+drain same beat, refresh, re-snapshot; (6) footer-swap for refusals/errors + discard confirm; (7) attribution via a new pure `attribute.rs` (membership sets keyed by lnum); (8) line selection in both layouts (inline one-sided, SBS row-pair). — DONE (2026-07-07): shipped as EIGHT changesets `m4-cursor → m4-zoom → m4-attribute → m4-notify → m4-refresh → m4-stage → m4-select → m4-watch` (staging split into hunk/file vs line selection; refresh pulled out as shared infra for stage + watch). Stack-reviewed continuously on the main thread; two real bugs caught by review, not by agent tests: (a) m4-zoom sub-view panes rendered worktree text where index text belonged — fixed with per-role blob sourcing (`read_index_blob`); (b) m4-select applied a multi-hunk line selection as N independent patches, which libgit2 rejects because each per-hunk patch's line numbering assumes the others are present — fixed by merging into ONE `PatchText` (`ops::apply_line_selections`), pinned by a line-shift tripwire test. Acceptance met: staging parity dogfooded against real git (stage/unstage/discard hunk/file/line, partial-hunk selection); index watcher confirmed live (external `git add` auto-refreshes on the next Tick — the watcher polls `.git/index`'s signature, so it catches index writes, not bare worktree edits, matching its name). Runtime stayed sync (no threads); combined-native staging toggle and spike `--dump`/`--bench` modes remain deferred. -- **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). Design locked 2026-07-07 (plan artifact `cairn-ledger`, 9 forks): (1) source = per-changeset `ChangesetView`, committed changesets built via `DiffState::from_committed` (empty staged/unstaged sub-models); (2) mode = derived `is_committed` + targeted guards, leaning on the existing `effective_zoom` collapse (empty sub-diffs → combined-only for free); (3) outline = left side pane, all four modes (flat/tree/stack/stack-tree); (4) load = hybrid (eager per-changeset `DiffState`, lazy per-file `FileView`); (5) nav = continuous `]f`/`[f` across the stack + `]c`/`[c` changeset jumps; (6) open-at = honor the lib's `current` flag; (7) source scope = auto-detect Graphite else single uncommitted changeset (M2–M4 preserved, backward-compatible); (8) changeset indicator = new top winbar; (9) needs-restack = first-class glyph + amber color (the lib gives a real boolean, unlike the prototype's title-string suffix). — DONE (2026-07-07): shipped as FOUR changesets `m5-stack-source → m5-changeset-nav → m5-outline-core → m5-outline-tree`, each delegated to an `implementer` subagent and main-thread diff-read before the next landed. The M1 lib already provided `assemble_changesets` + the `diff_changeset` router, so M5 was almost entirely review-App wiring; the uncommitted layer becomes one changeset *inside* the stack, keeping all of M4's staging/zoom/attribution working on it while committed changesets render read-only. Two correctness fixes surfaced during implementation, neither in the plan: (a) a committed changeset's combined-role old side must read its `base` commit's tree, not live `HEAD` (`old_side_tree_for`); (b) skipping attribution for committed changesets is not just a guard — without it `Attribution::build(None, None)`'s empty sets miscolored every Add cell as "already staged" (dim), pinned by a render test. Acceptance met: dogfooded against this repo's own live 33-changeset Graphite stack via a PTY harness (winbar changeset counter, `]c`/`[c` nav, outline flat/stack/tree/stack-tree modes with correct tree guides, open-on-uncommitted-layer focus) — a clean exit, no panic, exercising the real `resolve_changesets`→`assemble_graphite` path the hand-built unit tests don't. Full workspace green (41 suites, 804 tests, 0 fail), clippy `-D warnings --all-targets --all-features` clean. Deferred: Git-inference (`StackModel::Git`) and explicit ref-range review (the broader "ref sources") — auto-detect ships Graphite-or-uncommitted only; a fixed 35-col outline with no narrow-terminal handling. -- **M6 — git-workon CLI integration.** Ordered first: dependency-free, lowest-risk, and it unlocks dogfooding every later milestone through the real `git workon review` entry point (not `cargo run`). Cargo-style external-subcommand dispatch — `git-workon`'s unknown subcommand execs `git-workon-` on PATH with args passed through (none exists today; `Cmd` is a closed enum), so `git workon review` works via git's native `git-*` dispatch. Plus completion: the review binary gains `CompleteEnv` (its `Cli` is currently empty) so it is a `COMPLETE=` responder, and git-workon's dynamic completer enumerates `git-workon-*` on PATH and surfaces them as top-level subcommand candidates (so `git workon ` offers `review`). **Post-subcommand sub-delegation** (`git workon review ` → shell out to the review binary's completer) is **deferred, not built**: the review binary's `Cli` is currently empty (zero candidates), and MCP lands as `git workon mcp` (not a review subcommand — see M9), so there is nothing to delegate today. Its real trigger is *not* MCP — it's whenever the review binary gains its source-selector arg (`stack | uncommitted | | | pr-####`, the deferred v1 sources), whose values (refs, ranges, PR numbers) are genuinely completion-worthy. Wire delegation then, against that real surface; the review binary is already a `COMPLETE=` responder, so only the git-workon-side shell-out remains. Acceptance: `git workon review` dispatches with args through; `git workon ` lists external subcommands including `review`. DONE (2026-07-07): shipped as THREE changesets `m6-dispatch → m6-review-complete → m6-complete-enum` — (1) manual pre-parse PATH intercept (`dispatch.rs`), NOT clap `allow_external_subcommands` (which would break the flattened-`find.name` default-command routing); (2) review binary as `COMPLETE=` responder; (3) top-level external enumeration in the completer. Two seam facts surfaced: the clap_complete bash protocol needs `_CLAP_COMPLETE_INDEX` (word position) or it emits "no completion generated", and an empty `Cli` yields zero candidates (which is what made sub-delegation pointless to build). -- **M6.5 — everyday-usability pass (keybindings + theming + view-config).** Inserted ahead of M7 (2026-07-07): comments are deprioritized until the tool is usable for the author's own everyday review work. Keybindings and theming were never milestones — they were baked in as hardcoded values during M3–M5 (a `match` in `tui.rs`, a `const … Color::Rgb` block in `render.rs`). This pass makes both user-configurable and adds discoverability, plus gives previously-hardcoded view settings a config home. Design locked 2026-07-07; two ADRs: [ADR-034](../adr/034-review-git-native-config-schema.md) (git-native config schema — `workon.review.*`, action-as-key per-view keymaps, token grammar) and [ADR-035](../adr/035-review-theming-base16-hybrid.md) (hybrid base16 theming, render-time color resolution, terminal-derived `auto`). Scope: (1) `ReviewConfig` reader — the review binary reads git config for the first time; (2) action registry + configurable per-view keymaps, defaults unchanged; (3) help surface (persistent curated per-view footer + `?` overlay); (4) base16 `Theme` primitive + render-time resolution refactor (`FgSpan` carries capture index); (5) curated dark+light schemes + `theme=dark|light`; (6) `theme=auto` terminal-derivation OSC probe with curated fallback; (7) view-config (`outline.width`/`mode`, `diff.layout`/`zoom`). Full plan: `docs/plans/review-usability-pass.md`. Acceptance: rebind any diff/outline/global action via `git config`; `?` overlay + footer render the resolved map; `theme` selects auto/dark/light with terminal-derived `auto` degrading to curated on probe failure; view defaults honored from config. Comments (M7) resume after. +- **Crate scaffolding.** New member crate `git-workon-review` (lib+bin, clap, error model matching workspace: thiserror+miette). Toolchain bump (ratatui/tree-sitter won't meet 1.68.2; resolved: workspace-wide `rust-version = 1.88` — no crate had ever inherited the old value, so there was no lib MSRV to preserve). Lib hygiene (drop unused dialoguer/env_logger). CI: tree-sitter C builds. Release posture per [ADR-033](../adr/033-review-crate-workspace-placement.md): `publish = false` keeps the crate out of release-plz and cargo-dist entirely; release-plz wiring is deliberately deferred to the initial-renderer flip — do NOT add a release-plz.toml entry in crate scaffolding. Acceptance: `cargo build --workspace` green, empty `git-workon-review` binary runs and prints help. +- **Lib changeset assembly** (fixture extensions + lib stack capabilities, test-first). Fixture: sqlite metadata mode (also finally exercises the lib's primary read path), index-state builders. Lib: `parentBranchRevision` read (both formats) + needs-restack; git-inference StackModel; changeset assembly API (`Vec {branch, base_ref, head_ref, title, current, needs_restack}` + uncommitted layer). Acceptance: existing lib tests green + new capabilities spec'd against fixtures in both metadata formats. +- **Diff model and patch synthesis — trap corpus port.** Diff parser + patch synthesis in the review lib, the six trap items as tests, git2-vs-CLI verdict rendered (and the write-path decision recorded here). Acceptance: round-trip corpus green against real repos. — DONE (2026-07-06): corpus green on both backends; verdict recorded above. +- **Initial renderer — uncommitted source.** Port spike modules; wire changeset → parsed diff → SBS/inline render; file nav; the uncommitted source end-to-end. Acceptance: dogfood-able read-only review of a dirty worktree. — DONE (2026-07-06): combined-zoom read-only review with SBS + inline layouts, collapsed context gaps, word-diff emphasis, tree-sitter highlighting (spike's 8 grammars; syntect deferred), file/hunk nav; dogfooded against a dirty worktree. Port note: the spike's `compose_segments` had a latent first-match span-precedence bug that silently dropped word-level emphasis — fixed here (reverse-order lookup), pinned by a three-way bg test in `render.rs`. +- **Staging verbs — zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. Design locked 2026-07-06 (plan artifact `iron-lattice`): (1) staging = prototype parity — verbs act only in unstaged/staged panes, combined refuses, direction = pane role (combined-native toggle deferred); (2) cursor-primary nav in all views, scroll derived; (3) full 4-state zoom (`split→combined→unstaged→staged`) with per-file `_gate` downgrade and stacked split panes (per-pane cursor, `w` focus), no collapse debounce; (4) runtime stays sync — poll `IndexSignature` on Tick, synchronous re-diff (no threads/notify dep); (5) queue enqueue+drain same beat, refresh, re-snapshot; (6) footer-swap for refusals/errors + discard confirm; (7) attribution via a new pure `attribute.rs` (membership sets keyed by lnum); (8) line selection in both layouts (inline one-sided, SBS row-pair). — DONE (2026-07-07): shipped as EIGHT changesets `m4-cursor → m4-zoom → m4-attribute → m4-notify → m4-refresh → m4-stage → m4-select → m4-watch` (staging split into hunk/file vs line selection; refresh pulled out as shared infra for stage + watch). Stack-reviewed continuously on the main thread; two real bugs caught by review, not by agent tests: (a) m4-zoom sub-view panes rendered worktree text where index text belonged — fixed with per-role blob sourcing (`read_index_blob`); (b) m4-select applied a multi-hunk line selection as N independent patches, which libgit2 rejects because each per-hunk patch's line numbering assumes the others are present — fixed by merging into ONE `PatchText` (`ops::apply_line_selections`), pinned by a line-shift tripwire test. Acceptance met: staging parity dogfooded against real git (stage/unstage/discard hunk/file/line, partial-hunk selection); index watcher confirmed live (external `git add` auto-refreshes on the next Tick — the watcher polls `.git/index`'s signature, so it catches index writes, not bare worktree edits, matching its name). Runtime stayed sync (no threads); combined-native staging toggle and spike `--dump`/`--bench` modes remain deferred. +- **Stack and outline — ref sources.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). Design locked 2026-07-07 (plan artifact `cairn-ledger`, 9 forks): (1) source = per-changeset `ChangesetView`, committed changesets built via `DiffState::from_committed` (empty staged/unstaged sub-models); (2) mode = derived `is_committed` + targeted guards, leaning on the existing `effective_zoom` collapse (empty sub-diffs → combined-only for free); (3) outline = left side pane, all four modes (flat/tree/stack/stack-tree); (4) load = hybrid (eager per-changeset `DiffState`, lazy per-file `FileView`); (5) nav = continuous `]f`/`[f` across the stack + `]c`/`[c` changeset jumps; (6) open-at = honor the lib's `current` flag; (7) source scope = auto-detect Graphite else single uncommitted changeset (the diff-model-and-patch-synthesis-through-staging-verbs work preserved, backward-compatible); (8) changeset indicator = new top winbar; (9) needs-restack = first-class glyph + amber color (the lib gives a real boolean, unlike the prototype's title-string suffix). — DONE (2026-07-07): shipped as FOUR changesets `m5-stack-source → m5-changeset-nav → m5-outline-core → m5-outline-tree`, each delegated to an `implementer` subagent and main-thread diff-read before the next landed. The lib-changeset-assembly work already provided `assemble_changesets` + the `diff_changeset` router, so stack and outline was almost entirely review-App wiring; the uncommitted layer becomes one changeset *inside* the stack, keeping all of the staging-verbs work's staging/zoom/attribution working on it while committed changesets render read-only. Two correctness fixes surfaced during implementation, neither in the plan: (a) a committed changeset's combined-role old side must read its `base` commit's tree, not live `HEAD` (`old_side_tree_for`); (b) skipping attribution for committed changesets is not just a guard — without it `Attribution::build(None, None)`'s empty sets miscolored every Add cell as "already staged" (dim), pinned by a render test. Acceptance met: dogfooded against this repo's own live 33-changeset Graphite stack via a PTY harness (winbar changeset counter, `]c`/`[c` nav, outline flat/stack/tree/stack-tree modes with correct tree guides, open-on-uncommitted-layer focus) — a clean exit, no panic, exercising the real `resolve_changesets`→`assemble_graphite` path the hand-built unit tests don't. Full workspace green (41 suites, 804 tests, 0 fail), clippy `-D warnings --all-targets --all-features` clean. Deferred: Git-inference (`StackModel::Git`) and explicit ref-range review (the broader "ref sources") — auto-detect ships Graphite-or-uncommitted only; a fixed 35-col outline with no narrow-terminal handling. +- **CLI integration.** Ordered first: dependency-free, lowest-risk, and it unlocks dogfooding every later milestone through the real `git workon review` entry point (not `cargo run`). Cargo-style external-subcommand dispatch — `git-workon`'s unknown subcommand execs `git-workon-` on PATH with args passed through (none exists today; `Cmd` is a closed enum), so `git workon review` works via git's native `git-*` dispatch. Plus completion: the review binary gains `CompleteEnv` (its `Cli` is currently empty) so it is a `COMPLETE=` responder, and git-workon's dynamic completer enumerates `git-workon-*` on PATH and surfaces them as top-level subcommand candidates (so `git workon ` offers `review`). **Post-subcommand sub-delegation** (`git workon review ` → shell out to the review binary's completer) is **deferred, not built**: the review binary's `Cli` is currently empty (zero candidates), and MCP lands as `git workon mcp` (not a review subcommand — see stack operations below), so there is nothing to delegate today. Its real trigger is *not* MCP — it's whenever the review binary gains its source-selector arg (`stack | uncommitted | | | pr-####`, the deferred v1 sources), whose values (refs, ranges, PR numbers) are genuinely completion-worthy. Wire delegation then, against that real surface; the review binary is already a `COMPLETE=` responder, so only the git-workon-side shell-out remains. Acceptance: `git workon review` dispatches with args through; `git workon ` lists external subcommands including `review`. DONE (2026-07-07): shipped as THREE changesets `m6-dispatch → m6-review-complete → m6-complete-enum` — (1) manual pre-parse PATH intercept (`dispatch.rs`), NOT clap `allow_external_subcommands` (which would break the flattened-`find.name` default-command routing); (2) review binary as `COMPLETE=` responder; (3) top-level external enumeration in the completer. Two seam facts surfaced: the clap_complete bash protocol needs `_CLAP_COMPLETE_INDEX` (word position) or it emits "no completion generated", and an empty `Cli` yields zero candidates (which is what made sub-delegation pointless to build). +- **Usability pass** (keybindings + theming + view-config). Inserted ahead of the source-selector work (2026-07-07): comments are deprioritized until the tool is usable for the author's own everyday review work. Keybindings and theming were never milestones — they were baked in as hardcoded values during the initial-renderer-through-stack-and-outline work (a `match` in `tui.rs`, a `const … Color::Rgb` block in `render.rs`). This pass makes both user-configurable and adds discoverability, plus gives previously-hardcoded view settings a config home. Design locked 2026-07-07; two ADRs: [ADR-034](../adr/034-review-git-native-config-schema.md) (git-native config schema — `workon.review.*`, action-as-key per-view keymaps, token grammar) and [ADR-035](../adr/035-review-theming-base16-hybrid.md) (hybrid base16 theming, render-time color resolution, terminal-derived `auto`). Scope: (1) `ReviewConfig` reader — the review binary reads git config for the first time; (2) action registry + configurable per-view keymaps, defaults unchanged; (3) help surface (persistent curated per-view footer + `?` overlay); (4) base16 `Theme` primitive + render-time resolution refactor (`FgSpan` carries capture index); (5) curated dark+light schemes + `theme=dark|light`; (6) `theme=auto` terminal-derivation OSC probe with curated fallback; (7) view-config (`outline.width`/`mode`, `diff.layout`/`zoom`). Full plan: `docs/plans/review-usability-pass.md`. Acceptance: rebind any diff/outline/global action via `git config`; `?` overlay + footer render the resolved map; `theme` selects auto/dark/light with terminal-derived `auto` degrading to curated on probe failure; view defaults honored from config. Comments (now part of the agent-loop work) resume after. ### Roadmap reprioritized 2026-07-08 — personal daily-driver first (direction B) -The remaining roadmap is resequenced around the tool being **the author's own everyday diff-review + git surface**, not the agent-review loop (which becomes the eventual payoff once the tool is lived-in). This **supersedes the "comments next" ordering** and the decision-log **Positioning** / **v1 sources** rows above. Nothing past M6.5 is built, so renumbering is free. The old M7 (comments)/M8 (edit)/M9 (MCP) content is *relocated*, not dropped: edit-flow graduates into the daily-core (new **M10**); comments + MCP defer together into the agent-loop milestone (new **M13**). Ordering rationale is inline per bullet. +The remaining roadmap is resequenced around the tool being **the author's own everyday diff-review + git surface**, not the agent-review loop (which becomes the eventual payoff once the tool is lived-in). This **supersedes the "comments next" ordering** and the decision-log **Positioning** / **v1 sources** rows above. Nothing past the usability pass is built, so renumbering is free. The old comments/edit/MCP content is *relocated*, not dropped: edit-flow graduates into the daily-core (now editor jump); comments + MCP defer together into the agent-loop milestone. Ordering rationale is inline per bullet. -- **Prerequisite — Land M3–M6.5** (process, parallel to features; not a numbered milestone). QA the unmerged M3→M6.5 tower → merge to `main` → reliable install (a local build on PATH is enough to dogfood; the [ADR-033](../adr/033-review-crate-workspace-placement.md) release/homebrew "M3 flip" is a deferrable sub-decision). Gates real daily use regardless of features. QA checklist in memory `review-tui-priority-everyday-use` (`theme=auto` responsiveness, `theme=light` canvas, committed-changeset nav). +- **Prerequisite — land the initial-renderer-through-usability-pass work** (process, parallel to features; not a numbered milestone). QA the unmerged tower spanning that work → merge to `main` → reliable install (a local build on PATH is enough to dogfood; the [ADR-033](../adr/033-review-crate-workspace-placement.md) release/homebrew "initial-renderer flip" is a deferrable sub-decision). Gates real daily use regardless of features. QA checklist in memory `review-tui-priority-everyday-use` (`theme=auto` responsiveness, `theme=light` canvas, committed-changeset nav). -- **M7 — review any source.** A source selector — `stack | uncommitted | | | pr-####` — so the tool reviews *anything*, not just the auto-detected stack/uncommitted state. **Ordered first:** it is the tool's core *read* identity, read-only (low-risk), independent of the write verbs, and the M1/M5 lib already provides `assemble_changesets` + the `diff_changeset` router — mostly source-arg parse → resolve to changeset(s) → existing pipeline. PR support reuses git-workon-lib's `pr.rs`. Also **completes M6's deferred completion sub-delegation** (its trigger was exactly this arg gaining completion-worthy values). Acceptance: `git workon review ` / `` / `pr-123` renders the right changeset(s); `git workon review ` completes sources. Design locked 2026-07-09 — [ADR-036](../adr/036-review-source-grammar.md) (one sniffed positional, keyword-over-ref precedence, shape-aware `` dispatch, git-diff dot semantics, gh-backed PR resolution, uncommitted-layer-on-HEAD-only, fail-before-TUI, offline completion, `ChangesetSource`→`ChangesetSpan` rename); execution plan `docs/plans/review-any-source.md` (five changesets `m7-span-rename → m7-source-keywords → m7-source-revs → m7-source-pr → m7-complete`). +- **Source selector — review any source.** A source selector — `stack | uncommitted | | | pr-####` — so the tool reviews *anything*, not just the auto-detected stack/uncommitted state. **Ordered first:** it is the tool's core *read* identity, read-only (low-risk), independent of the write verbs, and the lib-changeset-assembly and stack-and-outline lib work already provides `assemble_changesets` + the `diff_changeset` router — mostly source-arg parse → resolve to changeset(s) → existing pipeline. PR support reuses git-workon-lib's `pr.rs`. Also **completes the CLI-integration work's deferred completion sub-delegation** (its trigger was exactly this arg gaining completion-worthy values). Acceptance: `git workon review ` / `` / `pr-123` renders the right changeset(s); `git workon review ` completes sources. Design locked 2026-07-09 — [ADR-036](../adr/036-review-source-grammar.md) (one sniffed positional, keyword-over-ref precedence, shape-aware `` dispatch, git-diff dot semantics, gh-backed PR resolution, uncommitted-layer-on-HEAD-only, fail-before-TUI, offline completion, `ChangesetSource`→`ChangesetSpan` rename); execution plan `docs/plans/review-any-source.md` (five changesets `m7-span-rename → m7-source-keywords → m7-source-revs → m7-source-pr → m7-complete`). -- **M8 — commit operations.** Commit the staged changes without leaving the TUI — message editor (inline vs `$EDITOR`), Conventional-Commit-aware (enforced by `git-hooks/commit-msg`); **amend** the current commit; **fixup/absorb** staged changes into an earlier changeset in the stack. Closes the review→stage→**commit** loop — the acute daily-driver gap. Acceptance: stage in the TUI, commit/amend/fixup, verified against real git. +- **Commit operations.** Commit the staged changes without leaving the TUI — message editor (inline vs `$EDITOR`), Conventional-Commit-aware (enforced by `git-hooks/commit-msg`); **amend** the current commit; **fixup/absorb** staged changes into an earlier changeset in the stack. Closes the review→stage→**commit** loop — the acute daily-driver gap. Acceptance: stage in the TUI, commit/amend/fixup, verified against real git. -- **M9 — stack operations.** Graphite stack verbs from the TUI: create a changeset/branch from staged (`gt create`), restack (`gt restack`), submit → PRs (`gt submit`), checkout/switch to a changeset (nav exists; actual checkout does not). Advanced reorder/fold/split deferred within. Builds on M8 — commit → create → submit is the shipping spine of a stacked workflow. Acceptance: create/restack/submit/checkout a changeset from the TUI against a real gt stack. +- **Stack operations.** Graphite stack verbs from the TUI: create a changeset/branch from staged (`gt create`), restack (`gt restack`), submit → PRs (`gt submit`), checkout/switch to a changeset (nav exists; actual checkout does not). Advanced reorder/fold/split deferred within. Builds on the commit-operations work — commit → create → submit is the shipping spine of a stacked workflow. Acceptance: create/restack/submit/checkout a changeset from the TUI against a real gt stack. -- **M10 — editor jump / edit flow** *(was M8)*. Jump from a diff line to `file:line` — embedded `nvim --server $NVIM --remote + `, standalone `$EDITOR + ` (detect via `$NVIM`); file watcher refreshes the diff on external save — port the prototype's debounced repo-root watcher (`FocusGained` fallback, viewport-preserving refresh, selection clamp; the Neovim mechanism doesn't translate, the behavior does). **Graduated from agent-loop into daily-core:** under (B) you review and want to *fix* the thing. Acceptance: jump opens the right file+line; saving refreshes without losing viewport. +- **Editor jump / edit flow.** Jump from a diff line to `file:line` — embedded `nvim --server $NVIM --remote + `, standalone `$EDITOR + ` (detect via `$NVIM`); file watcher refreshes the diff on external save — port the prototype's debounced repo-root watcher (`FocusGained` fallback, viewport-preserving refresh, selection clamp; the Neovim mechanism doesn't translate, the behavior does). **Graduated from agent-loop into daily-core:** under (B) you review and want to *fix* the thing. Acceptance: jump opens the right file+line; saving refreshes without losing viewport. -- **M11 — polish.** Worktree-switch hub in the TUI (surface git-workon's create/find/prune/switch so the TUI is a hub — vs staying review-only; decide during design) + in-diff navigation (fuzzy jump-to-file, search-in-diff, context expand/collapse, ignore-whitespace toggle, copy `path:line`). Acceptance: per the design cut. + **Carried-forward gap from the 2026-07-27 stale-diff fix.** A load whose hunks were diffed against one revision while its blob read saw a later one now clamps instead of crashing, and `ensure_role_loaded` self-heals it with a one-shot re-diff. But that trigger is wired only into the **synchronous eager** load path. The ADR-037 deferred loader-thread path (`build_file_views`, off-thread with no `&mut App`) and its landing site `apply_file_ready` were deliberately left unwired — signalling a mismatch through `LoadedViews`/`FileLoadSpec` would have been a second plumbing path. Consequence: a file opened via the deferred path during a continuous-write race shows clamped-but-uncorrected content until the next tick or a manual refresh. Harmless while writes are incidental; **the watcher makes them routine**, so wire the deferred path as part of this milestone. -- **M12 — conflict resolution** *(stretch)*. Resolve merge/rebase conflicts in the SBS view. Large surface; may not make v1. +- **In-diff navigation.** Worktree-switch hub in the TUI (surface git-workon's create/find/prune/switch so the TUI is a hub — vs staying review-only; decide during design) + in-diff navigation (fuzzy jump-to-file, search-in-diff, context expand/collapse, ignore-whitespace toggle, copy `path:line`). Acceptance: per the design cut. -- **M13 — agent loop** *(the eventual north star; was M7 comments + M9 MCP)*. On-disk comment store keyed to `(changeset_id, path, side, lnum)` with a rebase-survival anchoring strategy + TUI comment UX (create/view/resolve, store-watch refresh), and a **unified `git workon mcp`** stdio server bridging git-workon-lib worktree tools (`agent-integration.md` Model C) *and* the comment store. **Open forks (unchanged, resolve at design time):** comment-store home — a lib both the review crate and `git-workon` depend on, since `git workon mcp` is a second consumer (reopens the "no separate core crate" decision); the anchoring strategy; MCP crate/transport (`rmcp` vs hand-rolled JSON-RPC-over-stdio). Deferred behind the daily-driver work. + **Open question — should the SBS cursor have a side?** (raised 2026-07-27 during the copy-`path:line` design.) Today `cursor` is a single row index spanning both halves of the side-by-side view; `SplitPane` is the *staged/unstaged* split, not old/new, and no keybinding or render signal distinguishes the two halves. Dogfooding confirms the halves are visually indistinguishable because there is nothing to distinguish — the state doesn't exist. Adding it means: a side dimension on the cursor, a key to move between halves, extending the `cursor_unfocused_bg` / `pane_header_focused_fg` focus signaling (currently wired to the staged/unstaged split), and a per-consumer decision for staging, line selection, search jump, and copy about whether they care about the side. Plausibly the right long-term model — it would also sharpen ignore-whitespace and line-ops — but it is a design conversation and its own slice, not a sub-decision of another feature. Copy `path:line` deliberately sidesteps it by always using the new side (old on pure-deletion rows). + + **Open question — does the user-facing zoom cycle earn its keep?** (raised 2026-07-27.) Distinguish two things currently sharing the name: `EffectiveZoom` (the derived `Single(Role)`/`Split` resolution) is load-bearing for the staged/unstaged split view itself and is not in question; the user-facing `Zoom` cycle (`Z`, the 4-variant enum `Split → Combined → Unstaged → Staged`, the config setting) is. Dogfooding reports little use for it — structurally so, since `effective_zoom` downgrades `Split` to a single pane unless a file has BOTH sub-diffs, making `Split`/`Unstaged`/`Combined` render identically in a purely-unstaged worktree. The feature is dormant rather than useless: it activates precisely in the partially-staged workflow that **the commit-operations work** makes central (`Staged`-solo to verify what will land, `Combined` to see the file whole while deciding what to stage next). Decision deferred until commit operations ships and supplies real usage evidence; the live options are to collapse the cycle to two states (auto ↔ combined) or leave it as-is. Note a known residual meanwhile: a zoom change resets the current search-match highlight even when the rendered content is identical (the layout-toggle case was fixed 2026-07-27; the zoom case was deliberately left, since preserving across genuinely different roles would be wrong and the right fix depends on this decision). + + **Data point — `Whole` accumulates exemptions** (added 2026-07-27; renamed from `Combined` 2026-08-31.) Every verb that needs to know WHICH side it acts on has had to carve `Whole` out by hand: staging (`stage_hunk`/`stage_file`/`unstage`/`discard`, five `notify_unstageable_refusal` call sites) and line selection (`start_selection`) all refuse outright, because `staging_role()` returns `None` there. The pattern is that `Whole` is a read-only view wearing the same clothes as two editable ones, so each new verb pays a "does this even mean anything here?" tax and the user pays it again as a refusal notice. Note the counter-evidence, though: `copy-lines` (in-diff navigation) needed NO exemption — its new-side-with-old-fallback rule is total, so `Whole` yanks fine. So the tax lands on MUTATING verbs specifically, not on all of them, which suggests the real question is whether `Whole` should be a zoom state at all versus a distinct read-only mode. Feeds the zoom-cycle decision above; do not resolve independently of it. + +- **Conflict resolution** *(stretch)*. Resolve merge/rebase conflicts in the SBS view. Large surface; may not make v1. + +- **Agent loop** *(the eventual north star)*. On-disk comment store keyed to `(changeset_id, path, side, lnum)` with a rebase-survival anchoring strategy + TUI comment UX (create/view/resolve, store-watch refresh), and a **unified `git workon mcp`** stdio server bridging git-workon-lib worktree tools (`agent-integration.md` Model C) *and* the comment store. **Open forks (unchanged, resolve at design time):** comment-store home — a lib both the review crate and `git-workon` depend on, since `git workon mcp` is a second consumer (reopens the "no separate core crate" decision); the anchoring strategy; MCP crate/transport (`rmcp` vs hand-rolled JSON-RPC-over-stdio). Deferred behind the daily-driver work. ## Orchestration notes -Main-thread implementation; subagents only for explore/plan/code-review fan-out. Model tiers: design-heavy work on the strongest model; well-understood ports (M2 corpus, M3 spike port) delegate well to mid-tier; mechanical work (fixture builders, CI wiring) to the fast tier. Review each milestone (`/code-review`) before landing; run the full workspace test suite per milestone, not per commit. +Main-thread implementation; subagents only for explore/plan/code-review fan-out. Model tiers: design-heavy work on the strongest model; well-understood ports (the diff-model-and-patch-synthesis corpus, the initial-renderer spike port) delegate well to mid-tier; mechanical work (fixture builders, CI wiring) to the fast tier. Review each milestone (`/code-review`) before landing; run the full workspace test suite per milestone, not per commit. diff --git a/git-workon-lib/src/changeset.rs b/git-workon-lib/src/changeset.rs index 1a43dfa9..a1c04058 100644 --- a/git-workon-lib/src/changeset.rs +++ b/git-workon-lib/src/changeset.rs @@ -1,7 +1,8 @@ //! Changeset assembly: turning a stack model + repository state into an ordered list of //! reviewable [`Changeset`]s for the worktree whose `HEAD` is a given branch. //! -//! This is the substrate the review TUI (M2+) consumes. It stays **diff-free**: every +//! This is the substrate the review TUI (from the diff-model-and-patch-synthesis work onward) +//! consumes. It stays **diff-free**: every //! [`Changeset`] carries resolved `git2::Oid` rev pairs (or the [`ChangesetSpan::Uncommitted`] //! marker), never a parsed diff. Detecting uncommitted changes uses `repo.statuses`, never //! `repo.diff_*`. diff --git a/git-workon-lib/src/stack.rs b/git-workon-lib/src/stack.rs index d9b613d6..d1d29604 100644 --- a/git-workon-lib/src/stack.rs +++ b/git-workon-lib/src/stack.rs @@ -72,7 +72,7 @@ pub enum StackModel { /// `upstream..HEAD`. Unlike [`StackModel::Graphite`]/[`StackModel::GhStack`], this carries /// no branch-level stack topology: [`enumerate_stacks`] and [`current_stack`] treat it as /// flat (same as [`StackModel::None`]), since there is no metadata to enumerate stacks - /// from or to group branches into. Only [`crate::assemble_changesets`] (M1 changeset + /// from or to group branches into. Only [`crate::assemble_changesets`] (lib changeset /// assembly) gives this variant meaning, walking `upstream..HEAD` per-commit. /// /// Not reachable via [`StackModel::detect`] — see the module docs' Default-on-behavior @@ -99,7 +99,8 @@ impl StackModel { /// stack-active. Auto-resolving to `Git` for every repository with an upstream-tracking /// branch would silently flip that routing for nearly every user. `Git` is reachable via /// explicit `workon.stackModel = git` config, or a caller mapping `None` to `Git` before - /// calling [`crate::assemble_changesets`] (the review crate does this from M3 onward). + /// calling [`crate::assemble_changesets`] (the review crate does this from the initial + /// renderer onward). /// /// **Graphite wins** when both tools' artifacts are present: `.graphite_repo_config` /// comes from an explicit, repo-wide `gt init`, while a `gh-stack` file can appear as a diff --git a/git-workon-lib/tests/suite/changeset.rs b/git-workon-lib/tests/suite/changeset.rs index 36dcaf9f..a1fe868c 100644 --- a/git-workon-lib/tests/suite/changeset.rs +++ b/git-workon-lib/tests/suite/changeset.rs @@ -206,11 +206,9 @@ fn graphite_current_branch_missing_ref_errors( } both_formats!(graphite_current_branch_missing_ref_errors); -// ── Trap 7 ───────────────────────────────────────────────────────────────────── +// ── Stale-metadata head ─────────────────────────────────────────────────────── -fn trap7_spans_stale_branch_revision_to_live_head( - format: MetadataFormat, -) -> Result<(), Box> { +fn stale_branch_revision_spans_to_live_head(format: MetadataFormat) -> Result<(), Box> { let fixture = FixtureBuilder::new() .metadata_format(format) .graphite_config(&["main"]) @@ -251,9 +249,9 @@ fn trap7_spans_stale_branch_revision_to_live_head( } Ok(()) } -both_formats!(trap7_spans_stale_branch_revision_to_live_head); +both_formats!(stale_branch_revision_spans_to_live_head); -fn trap7_bogus_parent_revision_errors(format: MetadataFormat) -> Result<(), Box> { +fn bogus_parent_revision_errors(format: MetadataFormat) -> Result<(), Box> { let bogus = "deadbeef".repeat(5); let fixture = FixtureBuilder::new() .metadata_format(format) @@ -278,10 +276,10 @@ fn trap7_bogus_parent_revision_errors(format: MetadataFormat) -> Result<(), Box< } Ok(()) } -both_formats!(trap7_bogus_parent_revision_errors); +both_formats!(bogus_parent_revision_errors); #[test] -fn trap7_corrupt_sqlite_db_errors() -> Result<(), Box> { +fn corrupt_sqlite_db_errors() -> Result<(), Box> { // Refs-format fixture with valid metadata, then garbage bytes at the sqlite db path — // proves the error isn't masked by a silent fallback to (valid!) refs metadata. let fixture = FixtureBuilder::new() diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index 35d16e6b..da3d3640 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -17,8 +17,9 @@ include = [ "LICENSE*", "README.md", ] -# Not yet published to crates.io: flip to publish this crate at M3 (per RFC). -# At that point: remove this line, add `[[package]] name = "git-workon-review"` +# Not yet published to crates.io: flipping to publish is deferred (a deferrable sub-decision, +# see ADR-033) until the crate is ready to release. At that point: remove this line, add +# `[[package]] name = "git-workon-review"` # to release-plz.toml (no version_group — versioned independently of the CLI), # and decide the `dist = false` posture below (the homebrew patch step in # .github/workflows/release.yml stamps man/completions into every @@ -58,8 +59,8 @@ tree-sitter-typescript.workspace = true unicode-width.workspace = true [package.metadata.dist] -# Redundant with publish = false today; load-bearing at the M3 flip so -# cargo-dist doesn't silently start shipping the (still undesigned) binary. +# Redundant with publish = false today; load-bearing once the publish flip lands (deferred, see +# ADR-033) so cargo-dist doesn't silently start shipping the (still undesigned) binary. dist = false [dev-dependencies] diff --git a/git-workon-review/README.md b/git-workon-review/README.md index 77355d32..da860c56 100644 --- a/git-workon-review/README.md +++ b/git-workon-review/README.md @@ -2,6 +2,6 @@ A standalone TUI for reviewing changesets — any branch/ref/range/stack. -This crate is scaffolding (M0): the binary builds and prints help, but no -review functionality exists yet. See `docs/rfc/workon-review.md` in the -workspace root for the full design. +Point it at a Graphite stack, a ref, a range, or an uncommitted worktree and it +opens a diff/outline TUI for reviewing and staging the changes. See +`docs/rfc/workon-review.md` in the workspace root for the full design. diff --git a/git-workon-review/src/acquire.rs b/git-workon-review/src/acquire.rs index e8904ae5..e8f597d6 100644 --- a/git-workon-review/src/acquire.rs +++ b/git-workon-review/src/acquire.rs @@ -13,29 +13,29 @@ use crate::model::DiffModel; /// The working-tree diffs a review session needs: the index against `HEAD` (staged), the /// working tree against the index (unstaged, including untracked content), and the fused -/// `HEAD` ↔ worktree view (combined) the M3 renderer reviews by default. +/// `HEAD` ↔ worktree view (whole) the renderer reviews by default. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorktreeDiffs { pub staged: DiffModel, pub unstaged: DiffModel, /// `HEAD`'s tree diffed straight against the working tree (index consulted only for /// untracked/ignore filtering), fusing staged and unstaged hunks on the same file into one - /// diff — the combined-zoom view the M3 renderer reviews (locked design decision #2). - pub combined: DiffModel, + /// diff — the whole-diff view the renderer reviews by default. + pub whole: DiffModel, } /// Diff `HEAD`'s tree against the index (staged), the index against the working tree -/// (unstaged), and `HEAD`'s tree against the working tree directly (combined), for a +/// (unstaged), and `HEAD`'s tree against the working tree directly (whole), for a /// [`ChangesetSpan::Uncommitted`] changeset. /// -/// The unstaged and combined sides both set `include_untracked`/`recurse_untracked_dirs`/ +/// The unstaged and whole sides both set `include_untracked`/`recurse_untracked_dirs`/ /// `show_untracked_content` so untracked files carry real content in the model (git2 gives /// `Delta::Untracked` natively here — no `/dev/null` header synthesis needed). `find_similar` /// runs on all three diffs before materialization so worktree renames (e.g. an untracked file /// that replaces a tracked one under a new name) surface as [`crate::model::FileStatus::Renamed`] /// rather than a delete+add pair — the read side already handles that status (corpus-proven). /// -/// The two untracked-including diffs (unstaged, combined) pass explicit +/// The two untracked-including diffs (unstaged, whole) pass explicit /// [`DiffFindOptions::for_untracked`] — plain `find_similar(None)`'s default flags (just /// `GIT_DIFF_FIND_RENAMES`) do NOT pair an untracked file with a workdir deletion; libgit2 /// requires `for_untracked` opted in separately for that side of the match. The staged diff @@ -64,15 +64,15 @@ pub fn diff_uncommitted(repo: &Repository) -> Result { unstaged_diff.find_similar(Some(&mut untracked_find))?; let unstaged = DiffModel::from_git2(&unstaged_diff)?; - let mut combined_diff = + let mut whole_diff = repo.diff_tree_to_workdir_with_index(Some(&head_tree), Some(&mut worktree_opts))?; - combined_diff.find_similar(Some(&mut untracked_find))?; - let combined = DiffModel::from_git2(&combined_diff)?; + whole_diff.find_similar(Some(&mut untracked_find))?; + let whole = DiffModel::from_git2(&whole_diff)?; Ok(WorktreeDiffs { staged, unstaged, - combined, + whole, }) } @@ -201,7 +201,8 @@ pub fn diff_changesets( } /// Resolve the changeset stack the review App opens on for the worktree whose `HEAD` is -/// `head_branch` (locked design decision M5-fork-7, "auto-detect"): the full Graphite stack +/// `head_branch` (locked design decision: auto-detect Graphite, else a single uncommitted +/// changeset): the full Graphite stack /// when one is active, or a single synthetic [`Changeset`] spanning the uncommitted worktree /// otherwise. /// @@ -210,8 +211,8 @@ pub fn diff_changesets( /// (see its module docs) rather than a reviewable uncommitted layer, and mapping `None` to /// [`StackModel::Git`] instead would make every branch without upstream tracking (the common /// case for a scratch/local branch) fail with `NoUpstream` before it ever got to review a dirty -/// tree — a regression from M2–M4's "just diff the worktree" default. So the non-Graphite case -/// is built directly here, matching exactly what `assemble_changesets`'s own +/// tree — a regression from the original "just diff the worktree" default. So the +/// non-Graphite case is built directly here, matching exactly what `assemble_changesets`'s own /// `insert_uncommitted_layer` would produce for a lone dirty tree: one `current` entry, no /// title, not needing a restack. pub fn resolve_changesets( diff --git a/git-workon-review/src/align.rs b/git-workon-review/src/align.rs index 436993c2..10f4cf91 100644 --- a/git-workon-review/src/align.rs +++ b/git-workon-review/src/align.rs @@ -9,7 +9,7 @@ //! This module reads only hunk counters (`old_start`/`old_count`/`new_start`/`new_count`), //! [`crate::model::Hunk::lines`], and each line's kind + `old_lnum`/`new_lnum`. Content is NOT //! read from hunk lines here — rendering reads full file text by line number so numbers and -//! content stay in sync (M4 concern; out of scope for this module). +//! content stay in sync (a staging-verbs concern; out of scope for this module). //! //! ## Lineno invariant //! @@ -22,7 +22,7 @@ //! so the pairing code below `expect()`s the lineno for the side each kind is documented to //! carry. //! -//! ## Progressive gap expansion (CS8) +//! ## Progressive gap expansion //! //! [`collapse_gaps`]'s collapsed [`DisplayRow::Gap`]/[`InlineRow::Gap`] markers each carry a //! `key` — the hidden run's start index in the pre-collapse [`AlignedRow`] space — so a caller @@ -75,6 +75,13 @@ impl AlignedRow { pub struct Aligned { pub rows: Vec, + /// Whether [`align_file`] had to clamp a hunk-gap or trailing-tail span whose old/new + /// lengths disagreed — see the clamps below for why this is a real, reachable runtime state + /// (stale diff geometry against a freshly-read blob) rather than a bug. `false` for the + /// common case where `hunks`/`old_line_count`/`new_line_count` were all derived from the + /// same file revision, which is every path except a load racing a concurrent workdir write + /// (see [`crate::app::FileView::load`]). + pub mismatched: bool, } fn gap_end(start: usize, count: usize) -> usize { @@ -119,6 +126,9 @@ pub fn align_file(hunks: &[Hunk], old_line_count: usize, new_line_count: usize) let mut rows = Vec::new(); let mut old_pos = 0usize; // count of old lines already emitted let mut new_pos = 0usize; + // Set when a gap or the tail below has to clamp instead of pairing 1:1 — see `Aligned:: + // mismatched`'s doc comment for why this is reachable at runtime rather than a bug. + let mut mismatched = false; for hunk in hunks { let old_start = hunk.old_start as usize; @@ -130,10 +140,18 @@ pub fn align_file(hunks: &[Hunk], old_line_count: usize, new_line_count: usize) let new_ge = gap_end(new_start, new_count); let old_gap = old_ge.saturating_sub(old_pos); let new_gap = new_ge.saturating_sub(new_pos); - debug_assert_eq!( - old_gap, new_gap, - "context gap between hunks must be equal length on both sides" - ); + // `old_gap`/`new_gap` disagreeing means `hunks` itself carries internally inconsistent + // geometry — every hunk in a single valid diff is self-consistent with its neighbors (all + // positions relative to the same two blobs), so this branch shouldn't fire for hunks this + // module actually receives today. But `align_file` has no way to verify a `hunks` slice + // it's handed is well-formed, and the tail clamp below proves a geometry assumption CAN + // silently break for a reason outside this function's control (a load racing a concurrent + // workdir write — see `Aligned::mismatched`'s doc comment). Treating this the same way — + // clamp and flag, don't assert — costs nothing and keeps both clamps symmetric rather + // than leaving one crash-on-mismatch path alive for a future caller to rediscover. + if old_gap != new_gap { + mismatched = true; + } let gap = old_gap.min(new_gap); for i in 0..gap { rows.push(AlignedRow { @@ -173,13 +191,17 @@ pub fn align_file(hunks: &[Hunk], old_line_count: usize, new_line_count: usize) new_pos = new_start + new_count.saturating_sub(1); } - // Tail gap after the last hunk (or the whole file, if there are no hunks). + // Tail gap after the last hunk (or the whole file, if there are no hunks). This IS the + // empirically-confirmed mismatch (unlike the inter-hunk gap above): `old_line_count`/ + // `new_line_count` are read from the full old/new text at LOAD time (a live workdir read for + // the new side, per `crate::app::FileView::load`), while `old_pos`/`new_pos` derive from + // `hunks`, acquired earlier — a concurrent write between the two makes the tail lengths + // disagree. Clamp to the shorter side and flag it rather than asserting. let old_tail = old_line_count.saturating_sub(old_pos); let new_tail = new_line_count.saturating_sub(new_pos); - debug_assert_eq!( - old_tail, new_tail, - "trailing context after the last hunk must be equal length on both sides" - ); + if old_tail != new_tail { + mismatched = true; + } let tail = old_tail.min(new_tail); for i in 0..tail { rows.push(AlignedRow { @@ -190,7 +212,7 @@ pub fn align_file(hunks: &[Hunk], old_line_count: usize, new_line_count: usize) }); } - Aligned { rows } + Aligned { rows, mismatched } } /// A row of the gap-collapsed display, layered over [`AlignedRow`]s. @@ -208,7 +230,7 @@ pub enum DisplayRow { /// Number of context lines kept around hunk content on each side of a gap. pub const CONTEXT_LINES: usize = 3; -/// How far a single collapsed gap has been expanded (CS8). Accumulates across repeated `Enter` +/// How far a single collapsed gap has been expanded. Accumulates across repeated `Enter` /// presses: `before`/`after` each independently widen how many rows are revealed at that edge of /// the gap, and `full` — once set — reveals the whole run regardless of `before`/`after`. /// @@ -390,8 +412,9 @@ fn measure_context_run( } /// The currently-hidden [`AlignedRow`] sub-range `[start, end)` for the gap keyed `key`, given -/// its current `expansion` (if any) — used by [`crate::app::FileView::scope_expand_gap`] (CS9) to -/// measure how much of a gap's hidden run a candidate tree-sitter scope range would additionally +/// its current `expansion` (if any) — used by [`crate::app::FileView::scope_expand_gap`] +/// (tree-sitter scope reveal) to measure how much of a gap's hidden run a candidate tree-sitter +/// scope range would additionally /// uncover. `None` when `key` no longer denotes an actual gap: not a context-run start, the run is /// too short to have collapsed in the first place, or `expansion` already reveals the whole run. /// @@ -426,7 +449,7 @@ pub(crate) fn gap_hidden_range( /// UNEXPANDED context run contains `aligned_idx`, or `None` when `aligned_idx` isn't inside a /// context run at all, or that run is too short to ever collapse (same `keep_before`/`keep_after`/ /// `run_len` test [`collapse_gaps_inner`] uses — a run collapse decision never depends on the -/// current [`GapExpansion`] state, only on the run's own length and position). M11 CS3 (search): +/// current [`GapExpansion`] state, only on the run's own length and position). The in-diff search: /// a match address lives in the pre-collapse `AlignedRow` space, so jumping to one that isn't /// currently visible needs this reverse lookup — "which gap, if any, would need expanding to /// reveal this row" — before [`crate::app::FileView::expand_gap`] can be called with the right key. @@ -927,10 +950,10 @@ mod tests { ); } - // ── CS8: progressive gap expansion ────────────────────────────────────── + // ── Progressive gap expansion ───────────────────────────────────────────── /// One change row, a run of `run_len` context rows, one more change row — the shape every - /// CS8 expansion test collapses. With `context = 3` the base hidden count is + /// progressive-gap-expansion test collapses. With `context = 3` the base hidden count is /// `run_len - 2 * 3`. fn change_then_context_run_then_change(run_len: usize) -> Vec { let mut rows = vec![change_row( @@ -952,8 +975,9 @@ mod tests { #[test] fn collapse_gaps_matches_collapse_gaps_with_expansions_over_an_empty_map() { // `collapse_gaps` is a thin wrapper — pin that it's byte-for-byte the same output as - // calling the expansion-aware entry point with nothing to expand (the pre-CS8 behavior - // every other test in this module already exercises via `collapse_gaps_with`). + // calling the expansion-aware entry point with nothing to expand (the pre-progressive- + // gap-expansion behavior every other test in this module already exercises via + // `collapse_gaps_with`). let rows = change_then_context_run_then_change(16); let via_collapse_gaps = collapse_gaps(&rows); let via_expansions = collapse_gaps_with_expansions(&rows, &HashMap::new()); @@ -982,9 +1006,9 @@ mod tests { } } - /// The single [`DisplayRow::Gap`]'s `(key, skipped)` in `display` — the CS8 expansion tests' - /// index-free lookup (the gap's display position depends on how much kept context precedes - /// it, which is exactly what these tests vary). + /// The single [`DisplayRow::Gap`]'s `(key, skipped)` in `display` — the + /// progressive-gap-expansion tests' index-free lookup (the gap's display position depends + /// on how much kept context precedes it, which is exactly what these tests vary). fn only_gap(display: &[DisplayRow]) -> (usize, usize) { display .iter() diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 3eb06f52..aec5a25a 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -2,12 +2,13 @@ //! highlight cache + word-diff cache), and navigation/scroll state. //! //! Ported from the `review-tui-spike` prototype's `model.rs` — renamed here because `model` -//! already means the diff model in this crate (see the M3 plan's naming rule). +//! already means the diff model in this crate (see the initial-renderer plan's naming rule). //! -//! Renders the **combined** (`HEAD` ↔ worktree) diff only (locked design decision #2 in the M3 -//! plan) — the staged/unstaged split zoom is M4. [`App`] owns its own [`git2::Repository`] -//! handle so it can lazily read blob/worktree content per file as the user navigates to it, -//! independent of whatever handle acquired the [`DiffModel`] it was built from. +//! Renders the staged/unstaged split when both sides have content, and the **whole** (`HEAD` +//! ↔ worktree, or `base` ↔ `head` for a committed changeset) diff otherwise — see +//! [`Role`]/[`EffectiveZoom`]. [`App`] owns its own [`git2::Repository`] handle so it can +//! lazily read blob/worktree content per file as the user navigates to it, independent of +//! whatever handle acquired the [`DiffModel`] it was built from. use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::path::Path; @@ -19,7 +20,7 @@ use workon::{Changeset, ChangesetSpan}; use crate::acquire::{ChangesetDiff, WorktreeDiffs}; use crate::align::{ align_file, collapse_gaps, collapse_gaps_with_expansions, gap_hidden_range, inline_rows, - AlignedRow, CellKind, DisplayRow, GapExpansion, InlineRow, Row, + AlignedRow, CellKind, DisplayRow, GapExpansion, InlineRow, Row, CONTEXT_LINES, }; use crate::apply::{Git2Applier, StageVerb}; use crate::config::RawViewConfig; @@ -48,7 +49,7 @@ const SCROLLOFF: usize = 2; /// [`App::hscroll_right`]. const HSCROLL_STEP: usize = 8; -/// Loaded, aligned, highlighted view of one file's combined diff. +/// Loaded, aligned, highlighted view of one file's diff, for one [`Role`]. /// /// Full text is read once per side, from whichever source the file's status says still exists: /// @@ -60,12 +61,13 @@ const HSCROLL_STEP: usize = 8; /// | Modified / Unmerged | `HEAD` blob at `path` | worktree file at `path` | /// /// The new side reads from the **worktree file on disk**, not the index blob — unstaged -/// content isn't in the object database; reading the staged (index) blob is an M4 concern (the -/// staged/unstaged split zoom). +/// content isn't in the object database; reading the staged (index) blob is a +/// staging-verbs concern (the staged/unstaged split zoom). #[derive(Debug)] pub struct FileView { - /// The pre-collapse row list [`Self::display`]/[`Self::inline`] derive from — retained (CS8) - /// so a gap can be re-collapsed with a wider [`GapExpansion`] window without re-diffing the + /// The pre-collapse row list [`Self::display`]/[`Self::inline`] derive from — retained + /// (progressive gap expansion) so a gap can be re-collapsed with a wider [`GapExpansion`] + /// window without re-diffing the /// file. `AlignedRow` is small/`Copy`, so cloning the whole vector per expansion is cheap /// relative to re-running `align_file`. aligned: Vec, @@ -74,7 +76,8 @@ pub struct FileView { /// [`Self::load`] — expansions are NOT preserved across a refresh; the view rebuilds from /// scratch and every gap re-collapses to its base window. See [`Self::expand_gap`]. expansions: HashMap, - /// The file's hunks, retained (CS8) alongside [`Self::aligned`] so [`Self::rebuild_rows`] can + /// The file's hunks, retained (progressive gap expansion) alongside [`Self::aligned`] so + /// [`Self::rebuild_rows`] can /// recompute [`Self::display_hunk`]/[`Self::inline_hunk`] after an expansion without needing /// the original [`FileChange`] back. hunks: Vec, @@ -118,6 +121,12 @@ pub struct FileView { display_hunk: Vec>, /// Inline-coordinate analog of [`Self::display_hunk`], indexed against [`Self::inline`]. inline_hunk: Vec>, + /// Carried straight from [`crate::align::Aligned::mismatched`] — this load's hunk geometry + /// disagreed with the old/new line counts it was aligned against (a concurrent workdir write + /// between diff acquisition and this load's blob read). `ensure_role_loaded` reads this once, + /// right after building the view, to decide whether to trigger a one-shot re-diff; the field + /// itself is inert afterward (nothing re-checks it later). + pub(crate) geometry_mismatch: bool, } impl FileView { @@ -125,9 +134,9 @@ impl FileView { /// already role-correct because `file` is that role's own [`FileChange`], but the surrounding /// text must match the same two revisions the hunks were diffed against, or context lines /// render one revision on one side and a different one on the other: - /// - old side: [`Role::Combined`]/[`Role::Staged`] read the `HEAD` blob; [`Role::Unstaged`] + /// - old side: [`Role::Whole`]/[`Role::Staged`] read the `HEAD` blob; [`Role::Unstaged`] /// reads the INDEX blob (unstaged is index ↔ worktree). - /// - new side: [`Role::Combined`]/[`Role::Unstaged`] read the worktree file when `new_tree` + /// - new side: [`Role::Whole`]/[`Role::Unstaged`] read the worktree file when `new_tree` /// is `None` (the uncommitted layer); for a committed changeset `new_tree` is the changeset's /// `head` commit tree, whose blob is read instead (its new side is `base..head`, not the /// current worktree). [`Role::Staged`] reads the INDEX blob (staged is `HEAD` ↔ index). @@ -143,7 +152,7 @@ impl FileView { let old_text = match file.status { FileStatus::Added | FileStatus::Untracked => String::new(), _ => match role { - Role::Combined | Role::Staged => read_head_blob(repo, head_tree, old_source_path), + Role::Whole | Role::Staged => read_head_blob(repo, head_tree, old_source_path), Role::Unstaged => read_index_blob(repo, old_source_path), }, }; @@ -151,7 +160,7 @@ impl FileView { let new_text = match file.status { FileStatus::Deleted => String::new(), _ => match role { - Role::Combined | Role::Unstaged => match new_tree { + Role::Whole | Role::Unstaged => match new_tree { Some(tree) => read_head_blob(repo, tree, &file.path), None => read_workdir_file(repo, &file.path), }, @@ -162,12 +171,12 @@ impl FileView { let old_lines: Vec = old_text.lines().map(str::to_string).collect(); let new_lines: Vec = new_text.lines().map(str::to_string).collect(); - let aligned = align_file(&file.hunks, old_lines.len(), new_lines.len()).rows; + let aligned = align_file(&file.hunks, old_lines.len(), new_lines.len()); let old_hl = ts.highlight_file(old_source_path, &old_text); let new_hl = ts.highlight_file(&file.path, &new_text); let mut view = Self { - aligned, + aligned: aligned.rows, expansions: HashMap::new(), hunks: file.hunks.clone(), old_text, @@ -184,6 +193,7 @@ impl FileView { inline_word_spans: HashMap::new(), display_hunk: Vec::new(), inline_hunk: Vec::new(), + geometry_mismatch: aligned.mismatched, }; view.rebuild_rows(); view @@ -238,7 +248,7 @@ impl FileView { self.inline_word_spans.clear(); } - /// Accumulate an expansion request for the gap keyed `key` (CS8's progressive reveal) and + /// Accumulate an expansion request for the gap keyed `key` (progressive gap expansion) and /// rebuild the derived rows. `more_before`/`more_after` ADD to whatever was already revealed /// at that edge (repeated `Enter` presses widen further); `full` is sticky — once set for this /// gap it stays set. A `key` with no matching gap in the current `display` is harmless: the @@ -295,7 +305,7 @@ impl FileView { changed } - /// CS9's scope-reveal: widen the gap keyed `key` to uncover a tree-sitter scope range + /// The tree-sitter scope reveal: widen the gap keyed `key` to uncover a tree-sitter scope range /// `[scope_start, scope_end]` (1-based, inclusive — as returned by /// [`crate::scope::enclosing_scope_lines`]) that encloses the gap's anchor line, in /// `anchor_prefers_new`'s frame (new-side lineno when `true`, old-side when `false` — see @@ -463,7 +473,7 @@ impl FileView { .unwrap_or_default() } - /// M11 CS3 (search): literal, smartcase matches of `query` against this file's PRE-collapse + /// The in-diff search: literal, smartcase matches of `query` against this file's PRE-collapse /// row space — see [`crate::search::compute_matches`]'s doc comment for why that space (not /// [`Self::display`]/[`Self::inline`]) is what's scanned. pub(crate) fn search_matches(&self, query: &str) -> Vec { @@ -476,10 +486,11 @@ impl FileView { } } -/// The tree a COMBINED-role [`FileView`]'s old side reads from (see [`FileView::load`]'s role +/// The tree a WHOLE-role [`FileView`]'s old side reads from (see [`FileView::load`]'s role /// table): the changeset's `base` commit for a committed changeset, or the live `HEAD` for the -/// uncommitted layer — the only case M2–M4 ever had, and what [`App::base_label`] already -/// names. A committed changeset's combined role is `base..head` (there is no staged/unstaged +/// uncommitted layer — the only case the crate ever had before the staging-verbs work, and +/// what [`App::base_label`] already +/// names. A committed changeset's whole role is `base..head` (there is no staged/unstaged /// split to disagree with it — see [`DiffState::from_committed`]), so the old side must read /// `base`'s blob, not whatever `HEAD` happens to be right now. /// @@ -502,10 +513,11 @@ fn old_side_tree_for(repo: &Repository, span: ChangesetSpan) -> Option Option Option { - debug_assert_ne!( - role, - Role::Combined, - "build_sub_role_view is non-Combined only" - ); + debug_assert_ne!(role, Role::Whole, "build_sub_role_view is non-Whole only"); if file.is_binary { return None; } @@ -586,10 +594,22 @@ fn read_head_blob(repo: &Repository, tree: &git2::Tree<'_>, path: &str) -> Strin /// (index ↔ worktree) view and the "new" side of a staged (`HEAD` ↔ index) view. Reads stage-0 /// (the ordinary, non-conflict entry); a path absent from the index (or with no stage-0 entry) /// reads as empty, same graceful-default posture as [`read_head_blob`]. +/// +/// Reloads the index from disk first. libgit2 caches a repository's index in memory and never +/// re-reads it on its own, and ADR-037's loader thread holds ONE `Repository` for the whole +/// session (`tui.rs`'s `spawn_loader_thread`) — so once any load has primed that handle's cache, +/// every later read on it returns the index as it stood BEFORE the main thread's staging write. +/// A staged view built that way gets a short new side, and each row past the stale blob's last +/// line renders its gutter with no text. `read(false)` reloads only when the on-disk index +/// actually changed, so the unchanged case costs a stat; a failed reload falls through to +/// whatever is cached rather than reading nothing at all. fn read_index_blob(repo: &Repository, path: &str) -> String { repo.index() .ok() - .and_then(|index| index.get_path(Path::new(path), 0)) + .and_then(|mut index| { + let _ = index.read(false); + index.get_path(Path::new(path), 0) + }) .and_then(|entry| repo.find_blob(entry.id).ok()) .map(|blob| String::from_utf8_lossy(blob.content()).into_owned()) .unwrap_or_default() @@ -603,14 +623,17 @@ fn read_workdir_file(repo: &Repository, path: &str) -> String { .unwrap_or_default() } -/// Default outline pane width (locked design: "~35 cols") — the CS7 +/// Default outline pane width (locked design: "~35 cols") — the view-config settings' /// (`workon.review.outline.width`) fallback when the setting is unset, out of range, or the -/// config read fails. Was a `render.rs`-local const before CS7; now App-owned state since it's +/// config read fails. Was a `render.rs`-local const before the view-config settings; now +/// App-owned state since it's /// configurable per session (see [`OutlineState::width`]). pub const DEFAULT_OUTLINE_WIDTH: u16 = 35; -/// Sane clamp bounds for `workon.review.outline.width` (CS7). Below `MIN_OUTLINE_WIDTH` the +/// Sane clamp bounds for `workon.review.outline.width` (the view-config settings). Below +/// `MIN_OUTLINE_WIDTH` the /// pane can't show a useful path fragment; above `MAX_OUTLINE_WIDTH` it would swallow the diff -/// pane on any reasonable terminal. Also addresses M5's deferred narrow-terminal papercut: a +/// pane on any reasonable terminal. Also addresses the stack-and-outline work's deferred +/// narrow-terminal papercut: a /// user on a narrow terminal can now set a smaller width instead of losing the diff pane /// entirely to a fixed 35-col outline. pub const MIN_OUTLINE_WIDTH: u16 = 10; @@ -626,18 +649,20 @@ pub enum Layout { Inline, } -/// Which of the three per-file diff roles a [`FileView`] is built from. The **combined** role is -/// `HEAD` ↔ worktree (the whole change); **unstaged** is index ↔ worktree; **staged** is `HEAD` ↔ -/// index. A file need not have a change in every role — an untracked file has only an unstaged -/// change; a freshly `git add`ed one only a staged change; a partially-staged file has all three. +/// Which of the three per-file diff roles a [`FileView`] is built from. The **whole** role is +/// `HEAD` ↔ worktree for an uncommitted changeset (`base` ↔ `head` for a committed one) — the +/// whole change with no staged/unstaged split; **unstaged** is index ↔ worktree; **staged** is +/// `HEAD` ↔ index. A file need not have a change in every role — an untracked file has only an +/// unstaged change; a freshly `git add`ed one only a staged change; a partially-staged file has +/// all three. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Role { - Combined, + Whole, Unstaged, Staged, } -/// `workon.review.diff.text` (see ADR-035's "Revised (CS11, diff foreground/background split)" +/// `workon.review.diff.text` (see ADR-035's "Revised (diff foreground/background split)" /// section): which foreground source changed lines render with. A **behavior selector, not a /// color** — it lives on `App` rather than [`crate::theme::Palette`] because it decides which /// already-resolved palette color a segment picks, not what a color IS. Context lines always keep @@ -657,84 +682,62 @@ pub enum DiffTextMode { Edit, } -/// The zoom the user *requested* via `Z` — persists across file navigation (like [`Layout`]). The -/// actual state rendered per file is [`EffectiveZoom`], resolved by [`effective_zoom`] from this -/// plus the file's available sub-diffs; a file lacking the requested role collapses to -/// [`Role::Combined`] rather than showing an empty pane. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Zoom { - /// Unstaged pane stacked above staged pane, each independently navigable. The default — - /// the gate downgrades it to a single pane for files that don't have both sub-diffs, so the - /// common all-unstaged worktree still renders as one pane. - #[default] - Split, - Combined, - Unstaged, - Staged, -} - -/// The zoom actually rendered for a given file this frame — the gated resolution of a [`Zoom`] -/// against that file's available sub-diffs (see [`effective_zoom`]). Either a single pane over one -/// [`Role`], or the two-pane [`EffectiveZoom::Split`]. +/// The state actually rendered for a given file this frame — the gated resolution of +/// [`App::split_focus`]/[`App::maximized`] against that file's available sub-diffs (see +/// [`effective_zoom`]). Either a single pane over one [`Role`], or the two-pane +/// [`EffectiveZoom::Split`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EffectiveZoom { Single(Role), Split, } -/// Resolve the requested [`Zoom`] to the [`EffectiveZoom`] a file can actually show, given which of -/// its sub-diffs exist (`has_unstaged`/`has_staged` = the file's path appears in that role's -/// `DiffModel`) and whether it's stageable at all (`can_stage` = non-binary in M4). +/// Resolve the diff pane's requested state — the focused split pane's role and whether it's +/// [`App::maximized`] — to the [`EffectiveZoom`] a file can actually show, given which of its +/// sub-diffs exist (`has_unstaged`/`has_staged` = the file's path appears in that role's +/// `DiffModel`) and whether it's stageable at all (`can_stage` = non-binary, per the staging- +/// verbs work). +/// +/// Rules (a pure gate, unit-tested against the full truth table — ADR-038, "`effective_zoom` +/// takes the new inputs and narrows"): +/// - not stageable → [`Role::Whole`] (binary files render the placeholder; no attribution); +/// - both sub-diffs, maximized → `Single(focus_role)`; +/// - both sub-diffs, not maximized → `Split`; +/// - unstaged only → `Single(Unstaged)`; +/// - staged only → `Single(Staged)`; +/// - neither → `Single(Whole)`. /// -/// Rules (a pure gate, unit-tested against the full truth table): -/// - not stageable → [`Role::Combined`] (binary files render the placeholder; no attribution); -/// - `Combined` → `Combined`; -/// - `Unstaged` → `Unstaged` if it has one, else `Combined`; -/// - `Staged` → `Staged` if it has one, else `Combined`; -/// - `Split` → `Split` only if it has BOTH sub-diffs; else downgrade to whichever single sub-diff -/// exists; else `Combined`. +/// Maximize applies only where the result would otherwise be `Split` — everywhere else the pane +/// already fills the body, so the flag is inert rather than special-cased. pub fn effective_zoom( - requested: Zoom, + focus_role: Role, + maximized: bool, has_unstaged: bool, has_staged: bool, can_stage: bool, ) -> EffectiveZoom { if !can_stage { - return EffectiveZoom::Single(Role::Combined); + return EffectiveZoom::Single(Role::Whole); } - match requested { - Zoom::Combined => EffectiveZoom::Single(Role::Combined), - Zoom::Unstaged => { - if has_unstaged { - EffectiveZoom::Single(Role::Unstaged) - } else { - EffectiveZoom::Single(Role::Combined) - } - } - Zoom::Staged => { - if has_staged { - EffectiveZoom::Single(Role::Staged) - } else { - EffectiveZoom::Single(Role::Combined) - } - } - Zoom::Split => { - if has_unstaged && has_staged { - EffectiveZoom::Split - } else if has_unstaged { - EffectiveZoom::Single(Role::Unstaged) - } else if has_staged { - EffectiveZoom::Single(Role::Staged) - } else { - EffectiveZoom::Single(Role::Combined) - } + if has_unstaged && has_staged { + if maximized { + EffectiveZoom::Single(focus_role) + } else { + EffectiveZoom::Split } + } else if has_unstaged { + EffectiveZoom::Single(Role::Unstaged) + } else if has_staged { + EffectiveZoom::Single(Role::Staged) + } else { + EffectiveZoom::Single(Role::Whole) } } -/// The valid config strings for one of the CS7 view-config enums, in declaration order — the -/// single source both the `parse_*` functions below and their warning messages -/// (`App::apply_view_config`, config-validation-completeness Decision 5) read from, so the +/// The valid config strings for one of the view-config settings' enums, in declaration order +/// — the single source both the `parse_*` functions below and their warning messages +/// (`App::apply_view_config`, invalid-value warnings name the allowed set and the fallback) +/// read from, so the /// "valid: …" list in a warning can never list a name the parser doesn't actually accept (or /// omit one it does). fn valid_options_list(options: &[(&str, T)]) -> String { @@ -758,8 +761,9 @@ fn default_option_name( .expect("T::default() has a canonical name listed in `options`") } -/// Look up `raw` in one of the CS7 `*_OPTIONS` tables below — `None` on anything not in -/// `options`, the "unrecognized" signal [`resolve_option`] falls back to a default and warns on. +/// Look up `raw` in one of the view-config settings' `*_OPTIONS` tables below — `None` on +/// anything not in `options`, the "unrecognized" signal [`resolve_option`] falls back to a +/// default and warns on. fn parse_option(options: &[(&str, T)], raw: &str) -> Option { options .iter() @@ -788,7 +792,8 @@ fn resolve_option( }) } -/// `workon.review.outline.mode` (CS7)'s valid config strings, kebab-cased mirrors of the +/// `workon.review.outline.mode` (the view-config settings)'s valid config strings, kebab-cased +/// mirrors of the /// [`OutlineMode`] variant names, in [`App::apply_view_config`]'s warning order. Resolved via /// [`resolve_option`] — [`App::apply_view_config`] falls back to [`OutlineMode::default`] and /// warns on anything not listed here. @@ -799,7 +804,8 @@ const OUTLINE_MODE_OPTIONS: &[(&str, OutlineMode)] = &[ ("stack-tree", OutlineMode::StackTree), ]; -/// `workon.review.outline.order` (CS3)'s valid config strings, kebab-cased mirrors of the +/// `workon.review.outline.order` (the outline side pane's stack-and-outline work)'s valid config +/// strings, kebab-cased mirrors of the /// [`OutlineOrder`] variant names. Resolved via [`resolve_option`] — [`App::apply_view_config`] /// falls back to [`OutlineOrder::default`] and warns on anything not listed here. const OUTLINE_ORDER_OPTIONS: &[(&str, OutlineOrder)] = &[ @@ -807,31 +813,24 @@ const OUTLINE_ORDER_OPTIONS: &[(&str, OutlineOrder)] = &[ ("base-first", OutlineOrder::BaseFirst), ]; -/// `workon.review.icons` (CS5)'s valid config strings, kebab-cased mirrors of the [`IconMode`] -/// variant names. Resolved via [`resolve_option`] — [`App::apply_view_config`] falls back to -/// [`IconMode::default`] (also `none` — CS5's no-auto-detection default) and warns on anything +/// `workon.review.icons` (file-status letters and opt-in nerd icons)'s valid config strings, +/// kebab-cased mirrors of the [`IconMode`] variant names. Resolved via [`resolve_option`] — +/// [`App::apply_view_config`] falls back to [`IconMode::default`] (also `none` — its +/// no-auto-detection default) and warns on anything /// not listed here. const ICON_MODE_OPTIONS: &[(&str, IconMode)] = &[("none", IconMode::None), ("nerd", IconMode::Nerd)]; -/// `workon.review.diff.layout` (CS7)'s valid config strings, mirroring the [`Layout`] variant +/// `workon.review.diff.layout` (the view-config settings)'s valid config strings, mirroring the +/// [`Layout`] variant /// names. Resolved via [`resolve_option`] — [`App::apply_view_config`] falls back to /// [`Layout::default`] and warns on anything not listed here. const DIFF_LAYOUT_OPTIONS: &[(&str, Layout)] = &[("sbs", Layout::Sbs), ("inline", Layout::Inline)]; -/// `workon.review.diff.zoom` (CS7)'s valid config strings, mirroring the [`Zoom`] variant names. -/// Resolved via [`resolve_option`] — [`App::apply_view_config`] falls back to [`Zoom::default`] -/// and warns on anything not listed here. -const DIFF_ZOOM_OPTIONS: &[(&str, Zoom)] = &[ - ("split", Zoom::Split), - ("combined", Zoom::Combined), - ("unstaged", Zoom::Unstaged), - ("staged", Zoom::Staged), -]; - -/// `workon.review.diff.text` (CS11)'s valid config strings, mirroring the [`DiffTextMode`] -/// variant names — see [ADR-035](../../../docs/adr/035-review-theming-base16-hybrid.md)'s -/// "Revised (CS11, diff foreground/background split)" section. Resolved via [`resolve_option`] +/// `workon.review.diff.text` (the diff foreground/background split)'s valid config strings, +/// mirroring the [`DiffTextMode`] variant names — see +/// [ADR-035](../../../docs/adr/035-review-theming-base16-hybrid.md)'s +/// "Revised (diff foreground/background split)" section. Resolved via [`resolve_option`] /// — [`App::apply_view_config`] falls back to [`DiffTextMode::default`] and warns on anything /// not listed here. const DIFF_TEXT_OPTIONS: &[(&str, DiffTextMode)] = &[ @@ -840,7 +839,8 @@ const DIFF_TEXT_OPTIONS: &[(&str, DiffTextMode)] = &[ ("edit", DiffTextMode::Edit), ]; -/// CS4: which outline row a Header/Dir cursor selection resolves to — [`App::summary_target`]'s +/// The summary panel: which outline row a Header/Dir cursor selection resolves to — +/// [`App::summary_target`]'s /// return type, and the input [`App::summary_for`] consumes to build the renderable summary. /// `render.rs`'s `render_summary` never matches on this directly — it only calls /// `App::summary_for`/renders the [`Summary`] that comes back. @@ -855,16 +855,17 @@ pub enum SummaryTarget { Dir { cs_idx: Option, path: String }, } -/// CS4: the renderable summary [`App::summary_for`] builds for a [`SummaryTarget`] — a thin -/// wrapper so `render.rs` has one return type to match on regardless of which kind of row was -/// selected. +/// The summary panel: the renderable summary [`App::summary_for`] builds for a +/// [`SummaryTarget`] — a thin wrapper so `render.rs` has one return type to match on regardless +/// of which kind of row was selected. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Summary { Changeset(summary::ChangesetSummary), Dir(summary::DirSummary), } -/// CS7: a stable identity for an outline File/Dir row, captured BEFORE a staging/discard op's +/// The outline staging verbs: a stable identity for an outline File/Dir row, captured BEFORE a +/// staging/discard op's /// `coordinated_refresh` rebuilds [`App::outline_items`]'s row list, so the row can be re-found /// (or gracefully lost, e.g. a fully-discarded file) afterward — see /// [`App::restore_outline_position`]. `cs_idx`/`path` mirror the row's own fields, EXCEPT a @@ -949,15 +950,17 @@ pub struct OutlineState { pub focused: bool, pub cursor: usize, pub mode: OutlineMode, - /// The outline pane's column width — `workon.review.outline.width` (CS7), defaulting to - /// [`DEFAULT_OUTLINE_WIDTH`]. Read by `render.rs` in place of the old fixed const. + /// The outline pane's column width — `workon.review.outline.width` (the view-config + /// settings), defaulting to [`DEFAULT_OUTLINE_WIDTH`]. Read by `render.rs` in place of the + /// old fixed const. pub width: u16, /// Top-of-viewport row index into [`App::outline_items`]'s row list, derived from `cursor` /// via the same scrolloff discipline as [`App::scroll`] (see [`App::derive_outline_scroll`]) — /// never written directly. pub scroll: usize, /// Which end of the stack the stack-shaped modes display first — `workon.review.outline.order` - /// (CS3), defaulting to [`OutlineOrder::HeadFirst`]. Read by [`App::outline_items`]. + /// (the outline side pane's stack-and-outline work), defaulting to + /// [`OutlineOrder::HeadFirst`]. Read by [`App::outline_items`]. pub order: OutlineOrder, /// Column pan offset (display columns) for the outline pane — the outline's own analog of /// [`App::hscroll`], since a long path is hard-clipped at the outline's fixed width just like @@ -967,7 +970,7 @@ pub struct OutlineState { /// here. Reset to `0` by [`App::outline_cycle_mode`] — the row list (and therefore the set of /// paths on screen) changes shape there, the same reason that resyncs the cursor. pub hscroll: usize, - /// CS5 (`outline-fold`): per-[`OutlineMode`] sets of collapsed [`FoldKey`]s — a Header row's + /// `outline-fold`: per-[`OutlineMode`] sets of collapsed [`FoldKey`]s — a Header row's /// changeset label PLUS its `cs_idx`, or a Dir row's full path (+ owning changeset `cs_idx` in /// `StackTree`) — see [`FoldKey`]'s own doc comment for why `cs_idx` is load-bearing there, /// not decorative (a changeset's `label` alone can collide with its own uncommitted layer's). @@ -978,7 +981,8 @@ pub struct OutlineState { /// outlives its own toggling row's disappearance and reappearance (e.g. a discard-then-recreate /// of the same path) for as long as the session runs. pub folds: HashMap>, - /// CS2 (`outline-filter`, M11): the fuzzy-filter query, `/` while the outline has focus opens. + /// The outline fuzzy filter (`outline-filter`): the fuzzy-filter query, `/` while the outline + /// has focus opens. /// Read fresh every [`App::outline_items`] call (via [`outline::fold_outline_filtered`]) /// rather than /// cached — persistence across a rebuild (staging op, mode cycle, refresh) is therefore free: @@ -987,7 +991,8 @@ pub struct OutlineState { /// [`Self::filter_focused`] for the two-focus model this pairs with. pub filter: PromptState, /// Whether the one-row filter input (not the outline row list) currently has keyboard capture - /// — the prototype's two-focus model (locked design #2 in the M11 plan): `/` sets this `true`; + /// — the prototype's two-focus model (locked design: two-focus input model, in the + /// in-diff navigation plan): `/` sets this `true`; /// `Enter`/`Esc` set it back to `false` while KEEPING [`Self::filter`]'s query; `Ctrl-c` clears /// the query AND sets this `false`. Meaningless unless [`OutlineState::focused`] is also /// `true` — the filter input can't have keyboard capture while the diff pane does. @@ -1003,6 +1008,17 @@ enum SplitPane { Staged, } +impl SplitPane { + /// The [`Role`] this pane renders — `Unstaged`'s top pane is [`Role::Unstaged`], `Staged`'s + /// bottom pane is [`Role::Staged`]. Feeds [`effective_zoom`]'s `focus_role` under maximize. + fn role(self) -> Role { + match self { + SplitPane::Unstaged => Role::Unstaged, + SplitPane::Staged => Role::Staged, + } + } +} + /// Cursor + derived scroll for a split's *unfocused* pane. The focused pane's equivalent state /// lives directly on [`App`] (`cursor`/`scroll`) so every existing cursor-moving method keeps /// operating on the focused pane unchanged; `w` swaps this in and out (see @@ -1013,10 +1029,11 @@ struct PaneState { scroll: usize, } -/// CS6: a staging op's pre-op position, captured by [`App::capture_position`] before +/// Staging preserves the diff position: a staging op's pre-op position, captured by +/// [`App::capture_position`] before /// `coordinated_refresh` and restored by [`App::restore_position`] after — so a staging op keeps /// the reviewer's place instead of `reset_panes`' first-hunk reseat (that reseat still runs for -/// every MANUAL nav: file/changeset switches, zoom cycles). `path` + `role` say WHERE (the same +/// every MANUAL nav: file/changeset switches, maximize toggles). `path` + `role` say WHERE (the same /// file, the pane the reviewer was in); `old_lineno`/`new_lineno` say WHAT (the acted-on row's /// position in `role`'s own coordinate frame — the two sides a role's rows are diffed against, /// per [`FileView::load`]'s table). Deliberately NO pre-op zoom snapshot: [`App::restore_position`] @@ -1104,14 +1121,16 @@ fn derive_scroll_value( /// One changeset's diff state: its [`workon::Changeset`] descriptor (name, source, restack /// status), the [`DiffState`] acquired for it, and its own per-file, per-role lazily built -/// [`FileView`] caches — the same three `views_*` vectors [`App`] held directly through M4, -/// now scoped per changeset since M5 reviews more than one at a time. +/// [`FileView`] caches — the same three `views_*` vectors [`App`] held directly through the +/// staging-verbs work, now scoped per changeset since the stack-and-outline work reviews more +/// than one at a time. /// /// A committed changeset's [`Self::diff`] has empty staged/unstaged sub-models (see /// [`DiffState::from_committed`]), which is enough on its own to render it read-only: the /// existing [`effective_zoom`] gate collapses `Split`/`Unstaged`/`Staged` to -/// [`EffectiveZoom::Single(Role::Combined)`] whenever both sub-diffs are absent — no -/// committed-specific rendering code needed for M5's spine (the mode-aware staging refusal and +/// [`EffectiveZoom::Single(Role::Whole)`] whenever both sub-diffs are absent — no +/// committed-specific rendering code needed for the stack-and-outline work's spine (the +/// mode-aware staging refusal and /// zoom lock riding this natural collapse are [`App::is_committed`]'s targeted guards). pub struct ChangesetView { pub cs: Changeset, @@ -1119,7 +1138,7 @@ pub struct ChangesetView { /// Per-file, per-role lazily built views (parallel to [`DiffState::files`]). A slot stays /// `None` until first access; a role slot ALSO stays `None` forever when that file has no /// change in that role (see [`App::ensure_role_loaded`]). - views_combined: Vec>, + views_whole: Vec>, views_unstaged: Vec>, views_staged: Vec>, /// ADR-037's per-changeset acquisition state. `Ready` for every changeset this changeset @@ -1151,7 +1170,7 @@ impl ChangesetView { Self { cs, diff, - views_combined: (0..n).map(|_| None).collect(), + views_whole: (0..n).map(|_| None).collect(), views_unstaged: (0..n).map(|_| None).collect(), views_staged: (0..n).map(|_| None).collect(), slot: ChangesetSlot::Ready, @@ -1221,7 +1240,7 @@ impl ChangesetView { self.diff.files.len() } - /// This changeset's combined file list — `App::outline_items` reads this to build the + /// This changeset's whole file list — `App::outline_items` reads this to build the /// outline's rows without reaching into [`Self::diff`] directly (private to this module). pub fn files(&self) -> &[FileChange] { &self.diff.files @@ -1241,7 +1260,8 @@ impl ChangesetView { } } -/// One content region the renderer painted this frame, in terminal cell coordinates (CS10). A +/// One content region the renderer painted this frame, in terminal cell coordinates (mouse +/// support). A /// deliberately tiny local shape rather than `ratatui::layout::Rect`: `app.rs` has no ratatui /// dependency today, and this keeps it that way — `render.rs` (which already depends on /// ratatui) converts a `Rect`'s content area into this when it writes [`App::hit_regions`]. @@ -1259,7 +1279,8 @@ impl Region { } } -/// The content regions the last frame painted (CS10), written by `render::render` (which clears +/// The content regions the last frame painted (mouse support), written by `render::render` (which +/// clears /// this to `Default` at the top of every frame first) and read by [`App::handle_click`]/ /// [`App::handle_wheel`] to hit-test a mouse event's `(col, row)` against the region under the /// pointer. A `None` field simply wasn't painted this frame — the outline is closed, or the @@ -1273,7 +1294,8 @@ pub struct HitRegions { pub staged: Option, } -/// Which content region a mouse event hit-tested into (CS10's `App::hit_test`) — the outline, +/// Which content region a mouse event hit-tested into (mouse support's `App::hit_test`) — the +/// outline, /// the single-zoom diff pane, or one half of a split, tagged with which [`SplitPane`] so the /// click/wheel handlers know whether to `toggle_split_focus` first. enum HitPane { @@ -1302,7 +1324,8 @@ pub struct App { current_cs: usize, pub current: usize, /// Row index, in the ACTIVE layout's coordinate space, of the highlighted navigation - /// anchor — THE nav state (locked decision #2 in the M4 plan). In a split this is the + /// anchor — THE nav state (the staging-verbs plan's locked decision that navigation is + /// cursor-primary, scroll derived). In a split this is the /// FOCUSED pane's cursor; the unfocused pane's lives in [`Self::alt`]. `scroll` is derived /// from this every time it moves, via [`Self::derive_scroll`]. pub cursor: usize, @@ -1330,38 +1353,47 @@ pub struct App { /// Content height of the outline pane, written by the renderer each frame — same discipline /// as [`Self::pane_height`]. Read by [`Self::derive_outline_scroll`]. pub outline_height: usize, - /// The content regions the last frame painted (CS10 mouse support) — see [`HitRegions`]'s + /// The content regions the last frame painted (mouse support) — see [`HitRegions`]'s /// doc comment. Cleared and re-written by `render::render` every frame; read by /// [`Self::handle_click`]/[`Self::handle_wheel`]. pub hit_regions: HitRegions, /// Label for the old side of the diff, shown next to a rename's `old_path` in the header. - /// M4 only reviews the uncommitted (`HEAD` ↔ worktree) diffs, so this is always `"HEAD"` - /// today; M5's committed-changeset zoom will want the changeset's actual base rev. + /// The staging-verbs work only reviews the uncommitted (`HEAD` ↔ worktree) diffs, so this + /// is always `"HEAD"` today; the stack-and-outline work's committed-changeset zoom will + /// want the changeset's actual base rev. pub base_label: String, highlighter: TsHighlighter, /// Current render layout; see [`Layout`]'s doc comment for the persistence contract. pub layout: Layout, - /// The requested zoom (cycled by `Z`); the effective per-file zoom is resolved each frame via - /// [`effective_zoom`]. Persists across file navigation, like [`Self::layout`]. - pub zoom: Zoom, - /// `workon.review.diff.text` (CS11) — which foreground source changed lines render with. - /// Read directly by `render.rs`, same as [`Self::layout`]/[`Self::zoom`]; see + /// Whether the focused split pane requests the whole body (toggled by `Z`); the effective + /// per-file resolution is [`effective_zoom`]. Persists across file navigation, like + /// [`Self::layout`] — see ADR-038, "`maximized` persists across file navigation and + /// refresh". Applies only where the gate would otherwise return `Split`; inert everywhere + /// else (ADR-038, "`effective_zoom` takes the new inputs and narrows"). + pub maximized: bool, + /// `workon.review.diff.text` (the diff foreground/background split) — which foreground source + /// changed lines render with. + /// Read directly by `render.rs`, same as [`Self::layout`]/[`Self::maximized`]; see /// [`DiffTextMode`]'s doc comment. pub diff_text: DiffTextMode, - /// Which split pane has focus. Only meaningful under [`EffectiveZoom::Split`]; reset to - /// `Unstaged` (the top pane) whenever a file opens or the zoom changes. + /// Which split pane has focus. Only meaningful under [`EffectiveZoom::Split`] or + /// [`Self::maximized`]; reset to `Unstaged` (the top pane) whenever a file opens, UNLESS + /// [`Self::maximized`] is set — see [`Self::reset_panes`] (ADR-038, "`reset_panes` + /// preserves `split_focus` when `maximized` is set"). split_focus: SplitPane, /// A transient, footer-rendered message — set by [`Self::notify`], cleared by /// [`Self::clear_notice`] (the latter called by the event loop on the next keypress, so a /// notice stays visible until the user acts). `None` renders the footer's normal hint string /// instead (see `render::render_footer`). pub notice: Option, - /// FIFO queue every staging verb enqueues through, then drains on the same beat (locked - /// decision #5). Going through the queue (rather than calling `ops::apply_*` directly) buys + /// FIFO queue every staging verb enqueues through, then drains on the same beat (the + /// staging-verbs work's locked decision: the queue enqueues and drains in the same beat). + /// Going through the queue (rather than calling `ops::apply_*` directly) buys /// the queue's lock-retry and panic isolation for free; because the drain is synchronous and /// a refresh follows before the next keystroke, only ever one op is in flight. queue: StagingQueue, - /// The default write path (M2 verdict): libgit2's `Repository::apply`. Held as the concrete + /// The default write path (the git2-vs-CLI round-trip verdict): libgit2's `Repository::apply`. + /// Held as the concrete /// type — [`crate::apply::Applier`] stays a trait for the CLI escape hatch, but the field is /// the default. applier: Git2Applier, @@ -1375,37 +1407,43 @@ pub struct App { /// space, exactly like [`Self::cursor`]: the selected range is /// `[min(anchor, cursor), max(anchor, cursor)]`, so `j`/`k` extend it for free as the cursor /// moves. Cancelled (not translated) whenever the coordinate space reshapes — layout toggle, - /// zoom change, file switch, split-focus swap — since a raw row index carries no meaning across - /// a reshape. + /// maximize toggle, file switch, split-focus swap — since a raw row index carries no meaning + /// across a reshape. pub selection_anchor: Option, - /// Trap-4/5 livelock/interlock state for the M4 index watcher (locked decision #4: a - /// synchronous poll-on-`Tick`, no threads). See [`Self::on_tick`] and + /// Live-index-staging-queue/refresh-echo-suppression livelock/interlock state for the + /// staging-verbs work's index watcher (locked decision: the runtime stays sync, polling the + /// index signature on Tick). See [`Self::on_tick`] and /// [`Self::coordinated_refresh`]. refresh_coordinator: RefreshCoordinator, /// The outline side pane's state — see [`OutlineState`]'s doc comment. Initialized by /// [`Self::from_changesets`] to open-when-`len() > 1`/unfocused/[`OutlineMode::default`] - /// (the "decided without interview" default in the M5 plan), and repositioned (never + /// (the "decided without interview" default in the stack-and-outline plan), and + /// repositioned (never /// rebuilt-from-scratch — `open`/`focused`/`mode` persist, like [`Self::layout`]/ - /// [`Self::zoom`]) by every diff-initiated nav and by [`Self::refresh`]. + /// [`Self::maximized`]) by every diff-initiated nav and by [`Self::refresh`]. outline: OutlineState, /// Opt-in nerd-font iconography — `workon.review.icons`, defaulting to [`IconMode::None`] /// (no auto-detection story exists — a terminal can't report the user's font). A TUI-wide /// appearance mode like the theme, not an outline view setting: it gates the outline's /// file/dir icons AND the summary panel's and winbar's glyphs (see `render.rs`). icon_mode: IconMode, - /// Whether the `?` help overlay is showing (CS3). While `true`, `tui::update` intercepts + /// Whether the `?` help overlay is showing (the help footer and `?` overlay). While `true`, + /// `tui::update` intercepts /// every key as a modal (mirroring [`Self::pending_confirm`]'s capture) — see its doc comment /// for the precedence between the two modals. pub help_visible: bool, /// The `git workon review []` argument the session was launched with, set via - /// [`Self::set_review_source`] (M7 CS2 fix). `None` means the session was launched via + /// [`Self::set_review_source`] (a stack/uncommitted-source-keywords fix). `None` means the + /// session was launched via /// no-argument auto-detect (`crate::acquire::resolve_changesets`); `Some(source)` means an - /// explicit ask (`stack`, `uncommitted`, and later CS3/CS4's ref/range/PR variants) that + /// explicit ask (`stack`, `uncommitted`, or the ``-and-range-resolution and + /// PR-reference-resolution work's ref/range/PR variants) that /// [`Self::refresh`] must re-resolve on every refresh, NEVER downgrade to auto-detect — a /// setter (rather than a constructor parameter) so `App::from_changesets`'s signature, and /// every existing test building through it, stays untouched. review_source: Option, - /// CS4's idle-deferred load switch. `false` (the default) keeps every pre-CS4 + /// Idle-deferred file loads' load switch. `false` (the default) keeps every pre-idle- + /// deferred-file-loads /// `open_current`/render-path behavior byte-identical, so the ~80 existing tests asserting /// eager loads keep passing unchanged. `main.rs` turns this on via [`Self::set_defer_loads`] /// right after construction; the event loop is what actually defers (see `tui.rs`'s @@ -1445,20 +1483,13 @@ pub struct App { /// touches a thread or a `Repository`-carrying `Sender` itself, so it stays constructible (and /// `refresh` stays synchronously testable) with nothing wired up to actually dispatch this. pending_wave: Option<(u64, Vec<(usize, Changeset)>)>, - /// Display label for the resolved [`crate::keymap::Command::CycleZoom`] binding, shown in - /// [`Self::notify_combined_refusal`]'s "cycle zoom" hint. `App` deliberately has no keymap - /// field (the keymap is threaded through `tui.rs`/`main.rs` separately), so `main.rs::seat_app` - /// sets this once at seat time from the resolved binding; defaults to `"Z"` — the command's - /// default binding — for every `App::new`/`from_changesets` path that never seats a keymap - /// (keeps existing unit tests passing without churn). - zoom_key_label: String, /// A `reload-config` (`R`) request, picked up (and cleared) by [`Self::take_config_reload_request`]. /// Mirrors [`Self::pending_wave`]'s request-flag shape: `App` can't own the `Keymap`/`Palette` - /// the reload swaps in (they're threaded through `tui.rs`/`main.rs`, same reason - /// [`Self::zoom_key_label`] is a label rather than a keymap reference), so it only raises the + /// the reload swaps in (they're threaded through `tui.rs`/`main.rs`), so it only raises the /// flag here and the event loop — which DOES hold those — does the actual reload. config_reload_requested: bool, - /// M11 CS3 (`diff-search`): the ACCEPTED search query, `/` in the diff view opens the prompt + /// The in-diff search (`diff-search`): the ACCEPTED search query, `/` in the diff view opens + /// the prompt /// to edit. Survives file/changeset switches (vim-register semantics — see /// [`Self::recompute_search`]'s doc comment for what recomputes it on which trigger). `None` /// while no search has ever been @@ -1475,12 +1506,22 @@ pub struct App { search_focused: bool, /// The CURRENT search text's matches (the live prompt buffer's while [`Self::search_focused`], /// else [`Self::search_query`]'s) against the focused pane's file, in file order — recomputed - /// by [`Self::recompute_search`] on every trigger the M11 CS3 plan names: prompt edits, accept, - /// abort, file/changeset switch, refresh, layout/zoom change. + /// by [`Self::recompute_search`]/[`Self::recompute_search_keep_current`] on every trigger the + /// in-diff-search plan names: prompt edits, accept, abort, file/changeset switch, refresh, zoom + /// change, layout change. search_matches: Vec, /// Index into [`Self::search_matches`] of the match the cursor is currently parked on — - /// `None` while merely previewing (typing, before `Enter`) or when there's nothing to park on. + /// `None` while merely previewing (typing, before `Enter`), when there's nothing to park on, or + /// after a trigger with no "cursor is parked on match N" claim to make (see + /// [`Self::recompute_search`] vs [`Self::recompute_search_keep_current`]). search_current: Option, + /// Set for the DURATION of a [`Self::coordinated_refresh`] triggered by + /// [`Self::handle_geometry_mismatch`] — guards against a refresh loop when a file is being + /// written to continuously: while `true`, a mismatch detected by a load nested inside that + /// refresh (its own `open_current` reloading the same file) is tolerated with the clamp + /// instead of triggering ANOTHER refresh. Always `false` outside that call; never persists + /// across separate load attempts, so the next one gets its own single retry. + refreshing_for_geometry_mismatch: bool, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -1506,8 +1547,9 @@ pub enum PendingOp { file_idx: usize, selections: Vec<(usize, LineSelection)>, }, - /// CS7: discard every file in `files` — `(changeset identity, file path)` pairs — from the - /// worktree: an outline File row's single target, or a Dir row's every file under its path. + /// The outline staging verbs: discard every file in `files` — `(changeset identity, file + /// path)` pairs — from the worktree: an outline File row's single target, or a Dir row's + /// every file under its path. /// Stored by [`ChangesetIdentity`] + PATH rather than raw `(cs_idx, file_idx)` indices /// because the confirm modal doesn't stop the tick beat: an external index change (e.g. /// `git add` from another terminal) can run a full refresh between `d` and `y`, rebuilding @@ -1561,10 +1603,11 @@ pub(crate) fn display_label(cs: &Changeset) -> String { } impl App { - /// Build an [`App`] reviewing a single uncommitted changeset — the M2–M4 shape, and still - /// what a non-Graphite (or clean-Graphite-tip) repo degrades to under M5's auto-detect - /// (locked decision #7): a one-element [`Self::changesets`], `current_cs = 0`, - /// `base_label = "HEAD"`. `test_support::app_from_fixture` and every existing M2–M4 test + /// Build an [`App`] reviewing a single uncommitted changeset — the original shape, and + /// still what a non-Graphite (or clean-Graphite-tip) repo degrades to under the stack-and- + /// outline work's auto-detect (locked decision: auto-detect Graphite, else a single + /// uncommitted changeset): a one-element [`Self::changesets`], `current_cs = 0`, + /// `base_label = "HEAD"`. `test_support::app_from_fixture` and every existing early test /// build through this constructor unchanged. pub fn new(repo: Repository, diffs: WorktreeDiffs) -> Self { let name = repo @@ -1586,7 +1629,8 @@ impl App { /// Build an [`App`] over an already-diffed changeset stack — `main.rs`'s entry point for /// both the Graphite-stack and single-uncommitted-changeset cases (the latter goes through /// [`Self::new`] instead, which is the same thing for a one-element stack). Opens on - /// whichever changeset the lib marked `current` (locked decision #6: "honor lib `current`, + /// whichever changeset the lib marked `current` (locked decision: open on whichever + /// changeset the lib marks current — "honor lib `current`, /// first file"), falling back to index `0` if none is marked. An empty `changesets` panics — /// `main.rs` and [`Self::new`] never call this with one. pub fn from_changesets(repo: Repository, changesets: Vec) -> Self { @@ -1596,10 +1640,11 @@ impl App { ); let current_cs = current_cs_index(&changesets); let base_label = base_label_for(&changesets[current_cs].cs); - // Default-open when the stack has more than one changeset (the M5 plan's - // "decided without interview" default — preserves the M4 full-width look for a lone - // uncommitted changeset), unfocused (the diff keeps initial keyboard focus so the user - // can start reading immediately), Stack mode (shows the structure M5 exists to surface). + // Default-open when the stack has more than one changeset (the stack-and-outline + // plan's "decided without interview" default — preserves the original full-width look + // for a lone uncommitted changeset), unfocused (the diff keeps initial keyboard focus so + // the user can start reading immediately), Stack mode (shows the structure the + // stack-and-outline work exists to surface). // Under the pure open/closed toggle (`o`) this is now a consistent split: `o` controls // visibility, `h`/[`App::focus_outline`] controls focus — so seeding open+unfocused here // doesn't fight the toggle the way it did under the old three-state cycle. @@ -1645,7 +1690,7 @@ impl App { base_label, highlighter: TsHighlighter::new(), layout: Layout::default(), - zoom: Zoom::default(), + maximized: false, diff_text: DiffTextMode::default(), split_focus: SplitPane::Unstaged, notice: None, @@ -1664,13 +1709,13 @@ impl App { generation: 1, wave_failure_notified: false, pending_wave: None, - zoom_key_label: "Z".to_string(), config_reload_requested: false, search_query: None, search_prompt: PromptState::new(), search_focused: false, search_matches: Vec::new(), search_current: None, + refreshing_for_geometry_mismatch: false, }; // Position the outline cursor on the changeset/file the lib marked `current` (the same // row `sync_outline_to_current` would reposition to after any diff-initiated nav) rather @@ -1682,21 +1727,14 @@ impl App { /// Record the `[SOURCE]` argument the review session was launched with, so /// [`Self::refresh`] re-resolves that same ask instead of silently falling back to - /// no-argument auto-detect (M7 CS2 fix). `main.rs` calls this right after + /// no-argument auto-detect (a stack/uncommitted-source-keywords fix). `main.rs` calls this + /// right after /// [`Self::from_changesets`] whenever a `[SOURCE]` argument was given; a no-argument launch /// never calls it, leaving [`Self::review_source`] at its `None` default. pub fn set_review_source(&mut self, source: Source) { self.review_source = Some(source); } - /// Set the display label shown in [`Self::notify_combined_refusal`]'s "cycle zoom" hint — - /// see the `zoom_key_label` field's doc comment. `main.rs::plumb_zoom_hint_and_warnings` calls - /// this with the resolved [`crate::keymap::Command::CycleZoom`] binding, both right after - /// `seat_app` constructs the `App` and on every `reload-config`. - pub fn set_zoom_key_label(&mut self, label: String) { - self.zoom_key_label = label; - } - /// The current `.git/index`'s cheap fingerprint (mtime + size), or `None` if the read fails — /// tolerated rather than propagated, since a transient read error (e.g. a concurrent git /// process mid-write) must not crash the TUI or wedge the tick loop; the next tick just tries @@ -1708,7 +1746,7 @@ impl App { /// Refresh wrapped with [`RefreshCoordinator`] bookkeeping — the entry point every refresh /// trigger (manual `r`, and the post-staging-op drain) must go through instead of calling /// [`Self::refresh`] directly, so `last_signature` stays current and a `Tick` right after - /// doesn't mistake our own write for an external one (trap 5's echo-suppression). + /// doesn't mistake our own write for an external one (refresh echo suppression). /// /// The signature is read AFTER `self.refresh()` runs, not before: `refresh`'s own diffing can /// itself touch the index's stat cache (see [`RefreshCoordinator::complete`]'s doc comment for @@ -1724,7 +1762,10 @@ impl App { } } - /// The periodic `Tick` hook (locked decision #4: sync poll, no threads/channels). Reads the + /// The periodic `Tick` hook (the staging-verbs work's locked decision that the runtime + /// stays sync, polling the index signature on Tick — since retired by ADR-037: the crate + /// now runs an input thread and a loader thread, but this poll itself is still a plain + /// synchronous call on every `Tick`). Reads the /// current index signature and, if [`RefreshCoordinator::note_index_event`] says it's a /// genuinely new, unseen state with no staging op in flight, runs a [`Self::coordinated_refresh`]. /// A failed signature read is a silent no-op (tolerated, see [`Self::index_signature`]) — the @@ -1752,7 +1793,7 @@ impl App { &mut self.changesets[self.current_cs] } - /// The active changeset's combined file list — `render.rs` and tests read this instead of + /// The active changeset's whole file list — `render.rs` and tests read this instead of /// the old `pub files` field, which moved onto [`ChangesetView`] (see [`Self::cur`]). pub fn files(&self) -> &[FileChange] { &self.cur().diff.files @@ -1793,16 +1834,18 @@ impl App { } /// Number of changesets in the reviewed stack — `1` for a non-Graphite (or - /// clean-Graphite-tip) repo, per locked decision #7. + /// clean-Graphite-tip) repo, per the locked decision that Graphite auto-detects, else a + /// single uncommitted changeset. pub fn changeset_count(&self) -> usize { self.changesets.len() } /// Whether the ACTIVE changeset is a committed range (`base..head`) rather than the /// uncommitted worktree layer — derived from [`workon::ChangesetSpan`] on every call rather - /// than cached (locked decision #2's "derive, don't store" mode gate). Drives every - /// committed-mode guard: the mode-aware staging refusal, skipping combined attribution (no - /// staged/unstaged sets exist to color by), and locking zoom to combined. + /// than cached (the staging-verbs work's locked decision that the mode gate is derived, + /// never cached). Drives every + /// committed-mode guard: the mode-aware staging refusal, skipping whole-role attribution (no + /// staged/unstaged sets exist to color by), and locking zoom to whole. pub fn is_committed(&self) -> bool { matches!( self.cur().cs.span, @@ -1866,7 +1909,8 @@ impl App { /// above), leaves the rest of `Self::changesets` untouched and sets an error [`Notice`] /// instead (via [`Self::notify`]) — a failed refresh must never blank the review. /// - /// Dispatches on [`Self::review_source`] (M7 CS2 fix): a no-argument launch (`None`) re-runs + /// Dispatches on [`Self::review_source`] (a stack/uncommitted-source-keywords fix): a + /// no-argument launch (`None`) re-runs /// today's auto-detect ([`crate::acquire::resolve_changesets`]); an explicit-source launch /// (`Some`) re-runs [`crate::source::resolve_source`] against THAT source, never auto-detect /// — every ref-shaped source variant (`Stack`, `Uncommitted`, `Ref`, `Range`) is offline, @@ -1995,34 +2039,19 @@ impl App { }; } - /// Resolve the [`EffectiveZoom`] for file `idx` this frame: the requested [`Self::zoom`] gated - /// against that file's available sub-diffs and stageability. Cheap (three lookups + the pure - /// [`effective_zoom`]) — re-evaluated per file per frame, no caching (locked decision #3). + /// Resolve the [`EffectiveZoom`] for file `idx` this frame: [`Self::split_focus`]/ + /// [`Self::maximized`] gated against that file's available sub-diffs and stageability. Cheap + /// (three lookups + the pure [`effective_zoom`]) — re-evaluated per file per frame, no + /// caching (the per-file zoom gate is derived, never cached). pub(crate) fn effective_zoom_for(&self, idx: usize) -> EffectiveZoom { - let can_stage = self - .cur() - .diff - .files - .get(idx) - .map(|f| !f.is_binary) - .unwrap_or(false); - let has_unstaged = self - .cur() - .diff - .unstaged_idx - .get(idx) - .copied() - .flatten() - .is_some(); - let has_staged = self - .cur() - .diff - .staged_idx - .get(idx) - .copied() - .flatten() - .is_some(); - effective_zoom(self.zoom, has_unstaged, has_staged, can_stage) + let (can_stage, has_unstaged, has_staged) = self.stage_shape(idx); + effective_zoom( + self.split_focus.role(), + self.maximized, + has_unstaged, + has_staged, + can_stage, + ) } /// The role whose view [`Self::cursor`]/[`Self::scroll`] currently drive for file `idx`: the @@ -2035,14 +2064,13 @@ impl App { } /// The sub-[`FileChange`] backing file `idx`'s `role` view: `self.files[idx]` itself for - /// [`Role::Combined`], or the matching entry in the unstaged/staged model (`None` if that - /// role has no change for this file). Used by the renderer to build a fresh - /// [`crate::attribute::Attribution`] for the combined role each frame — see that module's - /// docs for why the two sub-roles' hunks (not the combined ones) are the attribution source. + /// [`Role::Whole`], or the matching entry in the unstaged/staged model (`None` if that + /// role has no change for this file). Used by staging verbs to apply against the ROLE's own + /// sub-diff rather than the whole one. pub(crate) fn role_change(&self, idx: usize, role: Role) -> Option<&FileChange> { let diff = &self.cur().diff; match role { - Role::Combined => diff.files.get(idx), + Role::Whole => diff.files.get(idx), Role::Unstaged => diff .unstaged_idx .get(idx) @@ -2077,7 +2105,7 @@ impl App { fn views_for(&self, role: Role) -> &[Option] { let cur = self.cur(); match role { - Role::Combined => &cur.views_combined, + Role::Whole => &cur.views_whole, Role::Unstaged => &cur.views_unstaged, Role::Staged => &cur.views_staged, } @@ -2086,7 +2114,7 @@ impl App { fn views_for_mut(&mut self, role: Role) -> &mut [Option] { let cur = self.cur_mut(); match role { - Role::Combined => &mut cur.views_combined, + Role::Whole => &mut cur.views_whole, Role::Unstaged => &mut cur.views_unstaged, Role::Staged => &mut cur.views_staged, } @@ -2095,8 +2123,9 @@ impl App { /// Read-only access to file `idx`'s already-loaded [`FileView`] for `role` (`None` if the role /// has no change for the file, or it isn't loaded yet). `pub` (not `pub(crate)`) so the /// separate `git-workon-review` bin crate's `tui.rs` tests can assert a file was — or, more - /// importantly, was NOT — loaded without visiting it (CS2's event-coalescing regression - /// test); read-only and does not touch `open_current`/`ensure_loaded`/`outline_move_by`'s + /// importantly, was NOT — loaded without visiting it (a coalescing-buffered-navigation- + /// input regression test); read-only and does not touch + /// `open_current`/`ensure_loaded`/`outline_move_by`'s /// eager-load semantics. pub fn role_view_ref(&self, idx: usize, role: Role) -> Option<&FileView> { self.views_for(role).get(idx).and_then(|v| v.as_ref()) @@ -2125,20 +2154,20 @@ impl App { } /// Load file `idx`'s [`FileView`] for one `role`, unless already loaded, binary, or the role - /// has no change for the file. The combined role builds from [`Self::files`]; the sub-roles + /// has no change for the file. The whole role builds from [`Self::files`]; the sub-roles /// build from the matching [`FileChange`] in the unstaged/staged model. Each role's text is /// sourced from the two revisions its hunks were diffed against (see [`FileView::load`]) so /// context lines match on both sides. fn ensure_role_loaded(&mut self, idx: usize, role: Role) { let model_idx = match role { - Role::Combined => { + Role::Whole => { let Some(file) = self.cur().diff.files.get(idx) else { return; }; if file.is_binary { return; } - if self.cur().views_combined.get(idx).map(Option::is_some) != Some(false) { + if self.cur().views_whole.get(idx).map(Option::is_some) != Some(false) { return; } None @@ -2147,7 +2176,7 @@ impl App { Role::Staged => self.cur().diff.staged_idx.get(idx).copied().flatten(), }; - if role != Role::Combined { + if role != Role::Whole { let Some(mi) = model_idx else { return; // no change in this role for this file }; @@ -2157,29 +2186,73 @@ impl App { let file = match role { Role::Unstaged => self.cur().diff.unstaged_model.files[mi].clone(), Role::Staged => self.cur().diff.staged_model.files[mi].clone(), - Role::Combined => unreachable!(), + Role::Whole => unreachable!(), }; // `file` is cloned out of `self.cur()` (rather than a borrow) because // `build_sub_role_view` needs `&self.repo` and `&mut self.highlighter` at once, which // a borrow still anchored in `self.cur()` would conflict with — same rationale as the - // combined path below. + // whole path below. let Some(view) = build_sub_role_view(&self.repo, &mut self.highlighter, role, &file) else { return; }; + if self.handle_geometry_mismatch(&view) { + // The nested refresh's own `open_current` already reloaded (and cached) this + // file/role through this same chokepoint — see `handle_geometry_mismatch`'s doc + // comment. Nothing left for this call to do. + return; + } self.views_for_mut(role)[idx] = Some(view); return; } - // Combined role. `self.cur().cs.span`/`self.cur().diff.files[idx].clone()` are read out + // Whole role. `self.cur().cs.span`/`self.cur().diff.files[idx].clone()` are read out // (rather than borrowed) for the same reason as the sub-role branch above — - // `build_combined_view` needs `&self.repo` and `&mut self.highlighter` together. + // `build_whole_view` needs `&self.repo` and `&mut self.highlighter` together. let span = self.cur().cs.span; let file = self.cur().diff.files[idx].clone(); - let Some(view) = build_combined_view(&self.repo, &mut self.highlighter, span, &file) else { + let Some(view) = build_whole_view(&self.repo, &mut self.highlighter, span, &file) else { return; }; - self.cur_mut().views_combined[idx] = Some(view); + if self.handle_geometry_mismatch(&view) { + return; + } + self.cur_mut().views_whole[idx] = Some(view); + } + + /// A just-built `view` whose [`FileView::geometry_mismatch`] is set means its hunks were + /// diffed against a DIFFERENT revision than the one [`FileView::load`] just read blobs from + /// (a concurrent workdir write racing the load — see [`crate::align::Aligned::mismatched`]). + /// Part 1's clamp already keeps that survivable, but a silently clamped tail is still wrong + /// content on screen, so this drives [`Self::coordinated_refresh`] to re-acquire the diff + /// against the file's CURRENT state instead of just rendering the clamp. + /// + /// Returns `true` when it triggered a refresh — the caller must NOT cache `view` in that case; + /// [`Self::coordinated_refresh`]'s own [`Self::refresh`] ends in [`Self::open_current`], which + /// re-enters [`Self::ensure_role_loaded`] for the same file and caches whatever THAT retry + /// produces. Returns `false` (view unaffected) when there's no mismatch, or when this IS that + /// retry — [`Self::refreshing_for_geometry_mismatch`] guards against a refresh loop for a file + /// under continuous writes: at most one refresh per load attempt. A mismatch that survives the + /// retry is accepted via the clamp, with a footer notice telling the user their diff may be + /// misaligned, rather than refreshing forever. + fn handle_geometry_mismatch(&mut self, view: &FileView) -> bool { + if !view.geometry_mismatch { + return false; + } + if self.refreshing_for_geometry_mismatch { + // No key hint here: `refresh` is a remappable binding this call site has no seated + // label for, and `App` deliberately has no keymap field to look one up from (the + // keymap is threaded through `tui.rs`/`main.rs` separately). + self.notify( + "file changed on disk while loading — diff may be misaligned; refresh to fix", + Severity::Info, + ); + return false; + } + self.refreshing_for_geometry_mismatch = true; + self.coordinated_refresh(); + self.refreshing_for_geometry_mismatch = false; + true } pub fn current_view(&mut self) -> Option<&mut FileView> { @@ -2207,20 +2280,29 @@ impl App { } /// Reset BOTH panes to their role views' first hunks and refocus the top (unstaged) pane — - /// run on file open and zoom change. The two role coordinate spaces disagree, so carrying a - /// raw cursor index across a role/zoom switch would be meaningless; jumping to the role's own + /// run on file open and maximize toggle. The two role coordinate spaces disagree, so carrying + /// a raw cursor index across a role switch would be meaningless; jumping to the role's own /// first hunk (the same position a fresh file open lands on) is always valid and predictable. /// + /// [`Self::split_focus`] is the one exception (ADR-038, "`reset_panes` preserves + /// `split_focus` when `maximized` is set"): while [`Self::maximized`] + /// is set, focus IS the view, so resetting it here would silently switch which role the + /// reviewer is reading on every file open. Preserved rather than reset in that case; reset to + /// `Unstaged` otherwise, same as before maximize existed. + /// /// This is also what `coordinated_refresh` leaves behind after a staging op (via - /// `open_current`), since a refresh is itself a file "open" of the post-op state — CS6's - /// `App::restore_position` runs immediately after, overwriting this first-hunk reseat with + /// `open_current`), since a refresh is itself a file "open" of the post-op state — staging + /// preserves the diff position: `App::restore_position` runs immediately after, overwriting + /// this first-hunk reseat with /// the reviewer's pre-op position when it can. Every OTHER caller (manual file/changeset - /// nav, zoom cycles) has no such follow-up, so first-hunk-on-open is still what they see. + /// nav, maximize toggles) has no such follow-up, so first-hunk-on-open is still what they see. fn reset_panes(&mut self) { - // Any file open / zoom change reshapes the coordinate space an active selection is keyed - // in, so drop it (see [`Self::selection_anchor`]). + // Any file open / maximize change reshapes the coordinate space an active selection is + // keyed in, so drop it (see [`Self::selection_anchor`]). self.selection_anchor = None; - self.split_focus = SplitPane::Unstaged; + if !self.maximized { + self.split_focus = SplitPane::Unstaged; + } self.alt = PaneState::default(); match self.effective_zoom_for(self.current) { EffectiveZoom::Single(role) => { @@ -2233,8 +2315,9 @@ impl App { } self.derive_scroll(); // The unfocused pane's scroll is derived at render time, once its height is known. - // M11 CS3: `reset_panes` is the one chokepoint every file/changeset switch, refresh, and - // zoom cycle already funnels through (`open_current`/`complete_pending_open` both end + // The in-diff search: `reset_panes` is the one chokepoint every file/changeset switch, + // refresh, and + // maximize toggle already funnels through (`open_current`/`complete_pending_open` both end // here) — see [`Self::recompute_search`]'s doc comment for the full trigger list. self.recompute_search(); } @@ -2246,7 +2329,8 @@ impl App { self.derive_scroll(); } - /// Turn CS4's idle-deferred load mode on/off. `main.rs` calls this with `true` right after + /// Turn idle-deferred file loads' idle-deferred load mode on/off. `main.rs` calls this with + /// `true` right after /// [`Self::from_changesets`], before the first [`Self::open_current`] — see the field's doc /// comment. Exposed as a setter (rather than folded into construction) so every existing test /// building through `from_changesets`/`App::new` keeps today's eager behavior untouched. @@ -2254,7 +2338,8 @@ impl App { self.defer_loads = on; } - /// Whether CS4's idle-deferred load mode is on — see [`Self::set_defer_loads`]. + /// Whether idle-deferred file loads' idle-deferred load mode is on — see + /// [`Self::set_defer_loads`]. pub fn defer_loads(&self) -> bool { self.defer_loads } @@ -2357,10 +2442,10 @@ impl App { let idx = self.current; let zoom = self.effective_zoom_for(idx); let diff = &self.cur().diff; - let combined_file = diff.files.get(idx)?.clone(); + let whole_file = diff.files.get(idx)?.clone(); Some(FileLoadSpec { span: self.cur().cs.span, - combined_file, + whole_file, zoom, unstaged_file: diff .unstaged_idx @@ -2444,10 +2529,10 @@ impl App { /// Either way, when the readied file IS the current pending open, it's seated like /// [`Self::complete_pending_open`]'s tail — with one refinement over a plain "always clear" /// rule: an `Ok` result only clears the pending open when its SHAPE satisfies the current - /// effective zoom (see [`loaded_views_satisfy`]). Without this, a zoom cycled mid-load + /// effective zoom (see [`loaded_views_satisfy`]). Without this, a maximize toggled mid-load /// (`Z` is exempt from force-completion — [`Self::open_current`] re-defers with /// `open_pending_dispatched = false`) lets the stale-shaped in-flight result seat only the - /// old view, clear the pending flags, and strand the new zoom's view forever un-dispatched. + /// old view, clear the pending flags, and strand the new shape's view forever un-dispatched. /// When unsatisfied, `open_pending` stays set and `open_pending_dispatched` resets to /// `false` so the next idle Tick re-dispatches against the NOW-current zoom — mirroring a /// fresh [`Self::open_current`] defer. An `Err` result keeps clearing unconditionally: a @@ -2515,7 +2600,8 @@ impl App { /// When `idx` IS the active changeset (the outline cursor already sits there — either it was /// the lib-marked `current` changeset at launch, or the user navigated onto its still-`Pending` /// placeholder), it's seated exactly as a fresh open would be: `current` resets to its first - /// file and [`Self::open_current`] runs (deferred-open semantics — CS4's placeholder shows + /// file and [`Self::open_current`] runs (deferred-open semantics — idle-deferred file + /// loads' placeholder shows /// until the file itself loads), then the outline cursor resyncs. Nothing here requires the /// user to navigate away and back for a just-readied active changeset to become interactive. pub fn apply_changeset_ready( @@ -2577,41 +2663,58 @@ impl App { } } - /// Cycle the requested zoom `Split → Combined → Unstaged → Staged → Split` (`Z`). The new zoom - /// persists across file navigation; both panes reset to their first hunks so `cursor`/`scroll` - /// are always valid for the now-active view(s). - pub fn cycle_zoom(&mut self) { - // A committed changeset has no staged/unstaged split to zoom into — lock zoom to combined - // (locked decision #2) rather than cycling into a state `effective_zoom` immediately - // collapses back anyway. - if self.is_committed() { - self.notify( - "changeset is committed — combined view only", - Severity::Info, - ); + /// The `(can_stage, has_unstaged, has_staged)` triple [`Self::effective_zoom_for`] and + /// [`Self::toggle_maximize`] both gate on for file `idx` — factored out so the maximize + /// no-op check can't drift from the render gate. + fn stage_shape(&self, idx: usize) -> (bool, bool, bool) { + let can_stage = self + .cur() + .diff + .files + .get(idx) + .map(|f| !f.is_binary) + .unwrap_or(false); + let has_unstaged = self + .cur() + .diff + .unstaged_idx + .get(idx) + .copied() + .flatten() + .is_some(); + let has_staged = self + .cur() + .diff + .staged_idx + .get(idx) + .copied() + .flatten() + .is_some(); + (can_stage, has_unstaged, has_staged) + } + + /// Toggle whether the focused split pane fills the whole body (`Z`) — ADR-038 decisions 2–4. + /// `effective_zoom` only lets maximize narrow a result that would otherwise be `Split`, so + /// this is a SILENT no-op (not a refusal) whenever the current file doesn't have both + /// sub-diffs: the user asked for a full-height pane and either already has one, or there is no + /// split to give one. The one exception is a committed changeset, which NEVER has a split to + /// maximize (see [`Self::is_committed`]'s doc comment) — that case keeps an informational + /// notice, worth stating once rather than leaving the key apparently dead. + pub fn toggle_maximize(&mut self) { + let (can_stage, has_unstaged, has_staged) = self.stage_shape(self.current); + if !(can_stage && has_unstaged && has_staged) { + if self.is_committed() { + self.notify( + "changeset is committed — nothing to maximize", + Severity::Info, + ); + } return; } - self.zoom = match self.zoom { - Zoom::Split => Zoom::Combined, - Zoom::Combined => Zoom::Unstaged, - Zoom::Unstaged => Zoom::Staged, - Zoom::Staged => Zoom::Split, - }; + self.maximized = !self.maximized; self.open_current(); } - /// Set the requested zoom directly — the config-startup (CS7) counterpart to - /// [`Self::cycle_zoom`]. Skips `cycle_zoom`'s committed-changeset guard: that guard exists - /// only to give interactive feedback when a cycle would be a no-op, not to enforce the - /// invariant itself — [`Self::effective_zoom_for`] (driven from [`Self::open_current`]'s - /// `reset_panes`, which [`Self::apply_view_config`]'s caller runs right after this) already - /// collapses a non-stageable changeset to [`Role::Combined`] regardless of the requested - /// zoom, so setting the raw value here can never bypass the gate. Does NOT call - /// `open_current` itself — the caller applies every CS7 setting first, then opens once. - pub fn set_zoom(&mut self, zoom: Zoom) { - self.zoom = zoom; - } - /// Swap focus between the two split panes (`w`) — swaps `cursor`/`scroll`/`pane_height` with /// the stashed unfocused pane so the existing cursor methods keep driving the focused pane, and /// re-derives the newly focused pane's scroll against its own (just-swapped-in) height. A no-op @@ -2728,12 +2831,13 @@ impl App { self.sync_outline_to_current(); } - // ── Outline side pane (CS3) ───────────────────────────────────────────────── + // ── The outline side pane (flat and stack modes) ────────────────────────────── - // ── Summary panel (CS4) ───────────────────────────────────────────────────── + // ── The summary panel ───────────────────────────────────────────────────────── /// Snapshot every reviewed changeset into [`OutlineChangeset`]/[`OutlineFile`] — the input - /// [`Self::outline_items`] feeds `outline::build_items`, and CS4's [`Self::summary_for`] + /// [`Self::outline_items`] feeds `outline::build_items`, and the summary panel's + /// [`Self::summary_for`] /// feeds `outline::latest_by_path` for a [`OutlineMode::Tree`] directory's cross-stack /// aggregate. Rebuilt fresh on every call, same posture as [`Self::outline_items`] itself. fn outline_snapshot(&self) -> Vec { @@ -2760,7 +2864,8 @@ impl App { } /// The current [`OutlineMode`]'s FOLD-FILTERED row list — [`Self::outline_items`]'s FOLD-ONLY - /// input, before CS2's fuzzy filter (if any) is layered on top. `render.rs`'s marker needs the + /// input, before the outline fuzzy filter (if any) is layered on top. `render.rs`'s marker + /// needs the /// per-row hidden-file counts this alone carries — see [`Self::outline_items_with_hidden_counts`]. /// Rebuilt fresh on every call (cheap: a small stack times a handful of files each, no /// caching, same posture as [`Self::effective_zoom_for`]) rather than cached on `App`, so it's @@ -2773,7 +2878,8 @@ impl App { }) } - /// CS2's fuzzy filter, REVISED 2026-07-24: filter-then-rebuild — the outline cursor's SINGLE + /// The outline fuzzy filter, REVISED 2026-07-24: filter-then-rebuild — the outline cursor's + /// SINGLE /// index space, and the source of truth every other outline consumer reads: `render.rs`, /// [`Self::outline_move_by`]/[`Self::outline_move_to`], [`Self::outline_confirm`], /// [`Self::summary_target`], and the staging-verb resolution in [`Self::outline_row_targets`] @@ -2811,8 +2917,9 @@ impl App { self.outline_filtered().items } - /// [`Self::outline_items`], plus (aligned by index) each row's CS5 hidden-file marker count - /// and CS2's fuzzy-match char indices (empty when no filter is active, or for a row that + /// [`Self::outline_items`], plus (aligned by index) each row's `outline-fold` hidden-file + /// marker count and the outline fuzzy filter's fuzzy-match char indices (empty when no filter + /// is active, or for a row that /// isn't itself a match — see [`outline::FilterMarks`]'s doc comment) — `render_outline`'s /// data source. pub fn outline_items_with_hidden_counts( @@ -2825,14 +2932,16 @@ impl App { /// Resolve a target row matched against the FULL (unfiltered, unfolded) row list to its /// position in [`Self::outline_items`]'s row list. /// - /// With NO CS2 fuzzy filter active: its own index if it's visible, or its nearest visible - /// (collapsed) ancestor's if a fold hides it (CS5's "`sync_outline_to_current` targeting a + /// With NO outline fuzzy filter active: its own index if it's visible, or its nearest visible + /// (collapsed) ancestor's if a fold hides it (`outline-fold`'s "`sync_outline_to_current` + /// targeting a /// file hidden under a collapsed node lands on the collapsed ancestor WITHOUT auto-expanding" /// rule — see [`outline::FoldedOutline::visible_index`]'s doc comment). `find` matches against /// the full build (via `outline::build_items` directly, not [`Self::outline_items`]) since a /// fold-hidden target has no index in the fold-filtered list at all to match against. /// - /// With a CS2 fuzzy filter active: `None` when the target row's own text didn't survive the + /// With the outline fuzzy filter active: `None` when the target row's own text didn't survive + /// the /// filter — REVISED 2026-07-24's rebuild DOES preserve ancestor Header/Dir rows, but `find` /// here always matches a specific `File` row's true `cs_idx`/`file_idx` (see /// [`Self::sync_outline_to_current`]'s call site), and a `File` row that didn't itself survive @@ -2840,8 +2949,9 @@ impl App { /// ancestor" fallback for a FILTERED-out file the way a FOLDED-hidden one gets, since the /// filter's ancestor rows carry no notion of "the file that would have been here." Callers /// (currently only [`Self::sync_outline_to_current`]) already treat `None` as "leave the - /// cursor where it is, clamped" — precisely the CS2 gotcha's "no-op instead of clearing the - /// filter" requirement, since neither branch here ever touches [`OutlineState::filter`] itself. + /// cursor where it is, clamped" — precisely the outline-fuzzy-filter gotcha's "no-op + /// instead of clearing the filter" requirement, since neither branch here ever touches + /// [`OutlineState::filter`] itself. fn outline_target_index(&self, find: impl Fn(&OutlineItem) -> bool) -> Option { if !self.outline.filter.is_empty() { return self.outline_items().iter().position(find); @@ -2852,7 +2962,8 @@ impl App { self.outline_folded().visible_index.get(full_idx).copied() } - /// CS4: the outline row a Header/Dir cursor selection resolves to — `None` when the outline + /// The summary panel: the outline row a Header/Dir cursor selection resolves to — `None` when + /// the outline /// isn't in a state where the diff area shows a summary instead of a file's diff (closed, /// merely open-but-unfocused, or the cursor is on a File row). `render_body` branches on this /// before any of its usual diff-body gates (pending/failed/binary/deferred-load). @@ -2934,12 +3045,13 @@ impl App { self.outline.cursor } - /// CS2 (`outline-filter`): the current filter query, for `render.rs`'s input-row line. + /// The outline fuzzy filter (`outline-filter`): the current filter query, for `render.rs`'s + /// input-row line. pub fn outline_filter_query(&self) -> &str { self.outline.filter.buffer() } - /// CS2: the filter input's own [`PromptState`] — `render.rs` calls + /// The outline fuzzy filter: the filter input's own [`PromptState`] — `render.rs` calls /// [`PromptState::render_line`] on it directly rather than `app.rs` doing so itself, keeping /// this module free of a `ratatui` dependency (see [`Region`]'s doc comment for the same /// discipline elsewhere in this file). @@ -2947,15 +3059,17 @@ impl App { &self.outline.filter } - /// CS2: whether the filter INPUT ROW (not the outline row list) currently has keyboard + /// The outline fuzzy filter: whether the filter INPUT ROW (not the outline row list) currently + /// has keyboard /// capture — see [`OutlineState::filter_focused`]'s doc comment for the two-focus model. pub fn outline_filter_focused(&self) -> bool { self.outline.filter_focused } - /// CS2: whether `render_outline` should paint the filter input row at all — non-empty query - /// OR input-focused (locked design: typing shows the row; leaving it focused with an empty - /// query still shows it, so the cursor has somewhere to render). `false` (the pre-CS2 default) + /// The outline fuzzy filter: whether `render_outline` should paint the filter input row at + /// all — non-empty query OR input-focused (locked design: typing shows the row; leaving it + /// focused with an empty query still shows it, so the cursor has somewhere to render). + /// `false` (the pre-outline-fuzzy-filter default) /// renders the outline exactly as before this changeset. pub fn outline_filter_active(&self) -> bool { self.outline.filter_focused || !self.outline.filter.is_empty() @@ -2975,9 +3089,9 @@ impl App { self.outline.hscroll } - /// The outline pane's column width — `workon.review.outline.width` (CS7), or - /// [`DEFAULT_OUTLINE_WIDTH`] if never set. Read by `render.rs` in place of the old fixed - /// const. + /// The outline pane's column width — `workon.review.outline.width` (the view-config + /// settings), or [`DEFAULT_OUTLINE_WIDTH`] if never set. Read by `render.rs` in place of + /// the old fixed const. pub fn outline_width(&self) -> u16 { self.outline.width } @@ -2986,7 +3100,8 @@ impl App { self.outline.mode } - /// Which end of the stack the outline displays first — `workon.review.outline.order` (CS3), + /// Which end of the stack the outline displays first — `workon.review.outline.order` (the + /// outline side pane's stack-and-outline work), /// or [`OutlineOrder::default`] if never set. pub fn outline_order(&self) -> OutlineOrder { self.outline.order @@ -3030,7 +3145,7 @@ impl App { self.outline.focused = false; } - // ── Mouse (CS10) ───────────────────────────────────────────────────────────── + // ── Mouse support ──────────────────────────────────────────────────────────── /// Hit-test `(col, row)` against [`Self::hit_regions`] — outline first, then the single diff /// pane, then the split's two halves — returning the matched region tagged with which @@ -3089,13 +3204,15 @@ impl App { self.derive_scroll(); } - /// Left-click at terminal `(col, row)` (CS10): focus + select whatever content region the + /// Left-click at terminal `(col, row)` (mouse support): focus + select whatever content region + /// the /// click landed in, matching the keyboard-driven equivalent for that region. Outline: focuses /// the outline and jumps the cursor to the clicked row via [`Self::outline_move_to`] — a File /// row jumps the diff there (same single-jump semantics `g`/`G` use), a Header/Dir row just /// selects (the summary panel follows via [`Self::summary_target`]) WITHOUT toggling its fold - /// (CS5, `outline-fold`) — a click has always been "move the cursor here", a strictly weaker - /// action than `Enter`'s "act on this row" even before folding existed (pre-CS5, `Enter` on a + /// (`outline-fold`) — a click has always been "move the cursor here", a strictly weaker + /// action than `Enter`'s "act on this row" even before folding existed (pre-`outline-fold`, + /// `Enter` on a /// Header jumped to its first file; a click on the same row never did), so a click staying /// select-only here keeps that existing asymmetry rather than inventing a new "click mirrors /// Enter" rule this pane never had. Diff pane (single or split): focuses that pane (flipping @@ -3220,7 +3337,8 @@ impl App { (self.outline.scroll as i64 + delta).clamp(0, max_scroll.max(0)) as usize; } - /// `?`: toggle the help overlay (CS3). A plain flip — the overlay always renders whatever + /// `?`: toggle the help overlay (the help footer and `?` overlay). A plain flip — the overlay + /// always renders whatever /// view currently has keyboard focus (see `render::render_help_overlay`), so there is no /// extra state to reposition here, unlike [`Self::toggle_outline`]. pub fn toggle_help(&mut self) { @@ -3238,7 +3356,8 @@ impl App { self.sync_outline_to_current(); } - /// Set the outline pane width directly (CS7: `workon.review.outline.width`, applied by + /// Set the outline pane width directly (the view-config settings: + /// `workon.review.outline.width`, applied by /// [`Self::apply_view_config`] at startup — there's no interactive key for this today). The /// caller is responsible for clamping into `[MIN_OUTLINE_WIDTH, MAX_OUTLINE_WIDTH]` /// (`apply_view_config` does); this setter trusts its input. @@ -3246,7 +3365,7 @@ impl App { self.outline.width = width; } - /// Set the outline mode directly — the config-startup (CS7) counterpart to + /// Set the outline mode directly — the config-startup (view-config settings) counterpart to /// [`Self::outline_cycle_mode`]. Unlike the interactive cycle, this does NOT call /// [`Self::sync_outline_to_current`]: [`Self::apply_view_config`] runs before the first /// [`Self::open_current`], matching how [`Self::from_changesets`] seeds @@ -3255,7 +3374,8 @@ impl App { self.outline.mode = mode; } - /// Set the outline stack order directly — the config-startup (CS3) counterpart there is no + /// Set the outline stack order directly — the config-startup (outline side pane's + /// stack-and-outline work) counterpart there is no /// interactive key for today. Same non-resync posture as [`Self::set_outline_mode`]: called /// before the first [`Self::open_current`], so no [`Self::sync_outline_to_current`] call is /// needed here either. @@ -3387,12 +3507,14 @@ impl App { } /// `Enter` while the outline has focus: a FILE row jumps the diff straight there and returns - /// focus to the diff (unchanged since CS3). A HEADER or DIR row instead TOGGLES that row's - /// fold state (CS5, `outline-fold`) and deliberately does NOT return focus — you're + /// focus to the diff (unchanged since the outline side pane's stack-and-outline work). A + /// HEADER or DIR row instead TOGGLES that row's fold state (`outline-fold`) and + /// deliberately does NOT return focus — you're /// manipulating the outline's own structure, not confirming a jump, so there's nothing to - /// hand focus back to yet. This REMOVES Enter's pre-CS5 jump-to-changeset-first-file behavior - /// on a Header row (still reachable via Enter on any of that changeset's own file rows, or - /// `[c`/`]c`) and Dir's pre-CS5 no-op (CS4 shipped Dir rows before any fold state existed to + /// hand focus back to yet. This REMOVES Enter's pre-`outline-fold` + /// jump-to-changeset-first-file behavior on a Header row (still reachable via Enter on any of + /// that changeset's own file rows, or `[c`/`]c`) and Dir's pre-`outline-fold` no-op (the + /// outline's path-trie tree modes shipped Dir rows before any fold state existed to /// toggle). pub fn outline_confirm(&mut self) { let items = self.outline_items(); @@ -3410,7 +3532,7 @@ impl App { } } - /// `Enter` on a Header/Dir row (CS5, `outline-fold`): flip that row's collapsed state in the + /// `Enter` on a Header/Dir row (`outline-fold`): flip that row's collapsed state in the /// CURRENT [`OutlineMode`]'s fold set (see [`OutlineState::folds`]), then re-derive the /// outline scroll — the row list's length just changed shape (more/fewer rows), the same /// reason every other row-count-changing op does. The cursor's own INDEX never needs @@ -3467,7 +3589,7 @@ impl App { self.sync_outline_to_current(); } - // ── Outline fuzzy filter (CS2 `outline-filter`, M11) ───────────────────────── + // ── Outline fuzzy filter (`outline-filter`) ────────────────────────────────── /// `/` while the outline has focus: give the filter input row keyboard capture. The keymap /// only ever dispatches this while [`OutlineState::focused`] is already `true` (it's a @@ -3570,7 +3692,7 @@ impl App { self.outline_filter_reflow(); } - // ── Diff search (CS3 `diff-search`, M11) ───────────────────────────────────── + // ── Diff search (`diff-search`) ─────────────────────────────────────────────── /// Whether the search prompt currently has keyboard capture (`/` opened it, `Enter`/`Esc` /// haven't closed it yet) — `tui.rs`'s modal-capture cascade arm and mouse-swallow guard, and @@ -3626,14 +3748,36 @@ impl App { } /// Recompute [`Self::search_matches`] from [`Self::active_search_text`] against the FOCUSED - /// pane's current file view — called on every trigger the M11 CS3 plan names: every prompt + /// pane's current file view — called on every trigger the in-diff-search plan names: every + /// prompt /// edit (live preview), accept/abort, file/changeset switch and refresh (both funnel through - /// [`Self::reset_panes`]), and a layout/zoom change (harmless to re-run even when the match - /// content can't have changed — matches address the layout-agnostic `AlignedRow` space). - /// [`Self::search_current`] always resets to `None` here: a fresh match list has no "the - /// cursor is parked on match N" claim to make until [`Self::search_accept`]/ - /// [`Self::search_next`]/[`Self::search_prev`] jumps to one. + /// [`Self::reset_panes`]), and a layout change (harmless to re-run even when the match content + /// can't have changed — matches address the layout-agnostic `AlignedRow` space). + /// [`Self::search_current`] resets to `None` here: a query edit, accept/abort, or a + /// file/changeset switch/refresh has no "the cursor is parked on match N" claim left to make + /// until [`Self::search_accept`]/[`Self::search_next`]/[`Self::search_prev`] jumps to one. fn recompute_search(&mut self) { + self.recompute_search_inner(None); + } + + /// [`Self::recompute_search`], but for a trigger that reshapes ONLY how the match list resolves + /// to rows — not the match list's own content or which file it's against (`toggle_layout` and + /// its `reload_view_config` echo, both same-file layout flips). Carries + /// [`Self::search_current`] across: captures the currently-current [`crate::search::SearchMatch`] + /// by value before recomputing, then re-finds its index in the new (address-identical) list and + /// restores it if still present — `SearchMatch` is `Copy + PartialEq`, so this is cheap. A + /// maximize toggle does NOT use this path even though it also funnels through `reset_panes`: + /// it swaps which role's (staged/unstaged/whole) content is current, a genuinely different + /// match list, so the plain reset in [`Self::recompute_search`] is the correct behavior there + /// too. + fn recompute_search_keep_current(&mut self) { + let prior = self + .search_current + .and_then(|i| self.search_matches.get(i).copied()); + self.recompute_search_inner(prior); + } + + fn recompute_search_inner(&mut self, prior_current: Option) { self.search_current = None; let Some(text) = self.active_search_text() else { self.search_matches.clear(); @@ -3648,6 +3792,9 @@ impl App { Some(view) => view.search_matches(&text), None => Vec::new(), }; + if let Some(m) = prior_current { + self.search_current = self.search_matches.iter().position(|&x| x == m); + } } /// `Enter` while the prompt is focused: commit the buffer as the accepted query, close the @@ -3824,12 +3971,196 @@ impl App { self.search_step(false); } + /// Which side (old or new) each row the active yank range covers resolves to — the rule the + /// yank-split handoff locks as "which side a row contributes (new side, old on pure + /// deletions)", shared by [`Self::resolve_copy_lines`] and + /// [`Self::resolve_copy_location`] so the two verbs cannot drift on side selection or gap + /// handling. Walks [`Self::selection_range`] (or the bare cursor row when no selection is + /// active) in the FOCUSED pane's ACTIVE layout coordinate space — the same space + /// [`Self::selection_range`] itself is already in, so no translation happens here. + /// + /// - **SBS**: the NEW side's lineno, falling back to the OLD side on a pure-deletion row that + /// carries no new side (the same rule the old single-row `copy-path-line` resolver used). + /// `DisplayRow::Gap` rows are skipped, never emitted. + /// - **Inline**: `Del` -> old lineno, `Add` -> new lineno, `Context` -> new lineno — mirroring + /// [`Self::selection_line_ops`]'s per-side-precise handling (locked decision: which side + /// a row contributes — new side, old on pure deletions). + /// `InlineRow::Gap` rows are skipped. + /// + /// Each entry is `(is_new_side, lineno)`, one per non-gap row in range order — the order the + /// caller needs both to pick text (per side) and to collapse a range to its first/last + /// lineno (the `path:lo-hi` range location format). `Err("no line to copy")` when nothing + /// in range yields a lineno at all: no file/view loaded, or the whole range is gap rows (gap + /// rows inside a range are skipped — a gap is hidden + /// content, skipping it silently is correct, but an ALL-gap range has nothing left to copy). + fn resolve_yank_rows(&self) -> Result, &'static str> { + let view = self.current_view_ref().ok_or("no line to copy")?; + let (lo, hi) = self.selection_range().unwrap_or((self.cursor, self.cursor)); + let mut rows = Vec::new(); + match self.layout { + Layout::Sbs => { + for r in lo..=hi { + let Some(row) = view.display.get(r) else { + continue; + }; + let (old, new) = display_row_linenos(row); + if let Some(n) = new { + rows.push((true, n)); + } else if let Some(n) = old { + rows.push((false, n)); + } + } + } + Layout::Inline => { + for r in lo..=hi { + match view.inline.get(r) { + Some(InlineRow::Del { old, .. }) => rows.push((false, *old)), + Some(InlineRow::Add { new, .. }) => rows.push((true, *new)), + Some(InlineRow::Context { new, .. }) => rows.push((true, *new)), + Some(InlineRow::Gap { .. }) | None => {} + } + } + } + } + if rows.is_empty() { + Err("no line to copy") + } else { + Ok(rows) + } + } + + /// The pure half of `copy-lines` (`y`): resolve the active yank range (the side- + /// contribution decision's + /// rules via [`Self::resolve_yank_rows`]) to the selected rows' raw TEXT, no I/O. One line + /// per resolved row, newline-joined, in range order — no `+`/`-` markers, no line numbers, no + /// path header (locked decision: copied content is raw code, undecorated — the dominant + /// use is pasting into a chat or a buffer, and + /// markers make the result non-compiling). Text comes straight from [`FileView::old_lines`]/ + /// [`FileView::new_lines`] indexed by the row's resolved lineno minus 1 — same-module private + /// fields, no accessor needed. + fn resolve_copy_lines(&self) -> Result { + let view = self.current_view_ref().ok_or("no line to copy")?; + let rows = self.resolve_yank_rows()?; + let lines: Vec<&str> = rows + .iter() + .map(|&(is_new, lineno)| { + let buf = if is_new { + &view.new_lines + } else { + &view.old_lines + }; + buf.get(lineno - 1).map(String::as_str).unwrap_or("") + }) + .collect(); + Ok(lines.join("\n")) + } + + /// The pure half of `copy-location` (`Y`): today's single-row `resolve_copy_path_line` + /// widened to a range. `path` is repo-relative, the same string already shown everywhere else + /// in this UI (outline, footer) — never an absolute path. + /// + /// `lo`/`hi` are the resolved rows' FIRST and LAST entries from [`Self::resolve_yank_rows`] + /// (the `path:lo-hi` range location format) — the range's endpoints in resolved-lineno + /// space, not raw row indices (a row + /// index is meaningless outside the TUI) and not a min/max sweep (a range's endpoints, per the + /// plan, not its extremes). A single-row selection, or no selection, collapses to today's + /// `path:12` form byte-for-byte; a genuine multi-row range emits `path:lo-hi`, not GitHub's + /// `path#L12-L18`. + fn resolve_copy_location(&self) -> Result { + let path = self + .files() + .get(self.current) + .map(|f| f.path.clone()) + .ok_or("no file to copy")?; + let rows = self.resolve_yank_rows()?; + let lo = rows + .first() + .expect("resolve_yank_rows never returns Ok(empty)") + .1; + let hi = rows + .last() + .expect("resolve_yank_rows never returns Ok(empty)") + .1; + if lo == hi { + Ok(format!("{path}:{lo}")) + } else { + Ok(format!("{path}:{lo}-{hi}")) + } + } + + /// Shared I/O-and-notify tail for [`Self::copy_lines`]/[`Self::copy_location`]: write + /// `payload` via OSC 52 ([`crate::clipboard::write_osc52`]) and post the footer notice on + /// either outcome, worded "copied ... to clipboard" — deliberately not "clipboard updated", + /// since OSC 52 is fire-and-forget (see the `clipboard` module doc) and this can only claim + /// the bytes reached the tty, never that the terminal actually honored them. Factored out so + /// the two verbs can't drift on wording. + /// + /// Returns whether the write succeeded, so the callers can honor the locked decision that a + /// successful yank clears the + /// selection on success" precisely: a failed write must LEAVE the selection intact, or the + /// user loses the range they built and has no way to retry the thing that just failed. + fn copy_payload(&mut self, payload: String) -> bool { + match crate::clipboard::write_osc52(&payload) { + Ok(()) => { + self.notify(format!("copied {payload} to clipboard"), Severity::Info); + true + } + Err(err) => { + self.notify(format!("clipboard write failed: {err}"), Severity::Error); + false + } + } + } + + /// `y` (default binding `copy-lines`): copy the active yank range's TEXT to the system + /// clipboard. See [`Self::resolve_copy_lines`] for resolution and [`Self::copy_payload`] for + /// the write. Clears the active selection on success (the locked decision that a + /// successful yank clears the selection, matching vim's `y` and + /// [`Self::stage_selection`]'s success paths) — NOT on either failure path (resolution error + /// or a failed clipboard write), so the user keeps the range they built and can retry. + pub fn copy_lines(&mut self) { + let payload = match self.resolve_copy_lines() { + Ok(payload) => payload, + Err(reason) => { + self.notify(reason, Severity::Error); + return; + } + }; + if self.copy_payload(payload) { + self.cancel_selection(); + } + } + + /// `Y` (default binding `copy-location`): copy the active yank range's `path:line` (or + /// `path:lo-hi`) to the system clipboard. See [`Self::resolve_copy_location`] for resolution + /// and [`Self::copy_payload`] for the write. Clears the active selection on success, same as + /// [`Self::copy_lines`] — not on either failure path. + pub fn copy_location(&mut self) { + let payload = match self.resolve_copy_location() { + Ok(payload) => payload, + Err(reason) => { + self.notify(reason, Severity::Error); + return; + } + }; + if self.copy_payload(payload) { + self.cancel_selection(); + } + } + /// Park the cursor on [`Self::search_matches`]`[idx]`: auto-expand the gap it's hidden behind /// (if any — [`crate::align::gap_key_for_aligned_idx`] + [`FileView::expand_gap`], the - /// existing CS8/CS9 machinery), then locate the row in the ACTIVE layout's own vector by the + /// existing progressive-gap-expansion/tree-sitter-scope-reveal machinery), then locate the + /// row in the ACTIVE layout's own vector by the /// match's (old, new) lineno pair and land there. `wrapped` raises the footer notice the plan /// calls for; a match whose row can't be located post-expansion (should be unreachable once /// expanded) leaves the cursor where it was rather than panicking. + /// + /// The reveal is BOUNDED, not full: widen only whichever gap edge sits nearer the match, by + /// just enough rows to surface it plus a small [`crate::align::CONTEXT_LINES`] margin, rather + /// than dumping the entire hidden run (`full: true`) the way an earlier round did. `expand_gap` + /// accumulates, so repeated jumps into the same gap widen it further rather than resetting — + /// deliberately not reset here. fn jump_to_search_match(&mut self, idx: usize, wrapped: bool) { let Some(&m) = self.search_matches.get(idx) else { return; @@ -3838,10 +4169,22 @@ impl App { if let Some(view) = self.current_view() { if let Some(key) = crate::align::gap_key_for_aligned_idx(&view.aligned, m.aligned_idx) { - let hidden = crate::align::gap_hidden_range(&view.aligned, key, &view.expansions) - .is_some_and(|(start, end)| m.aligned_idx >= start && m.aligned_idx < end); - if hidden { - view.expand_gap(key, 0, 0, true); + if let Some((start, end)) = gap_hidden_range(&view.aligned, key, &view.expansions) { + if m.aligned_idx >= start && m.aligned_idx < end { + // Reveal from whichever edge is nearer the match: `dist_to_start` rows lie + // between the hidden range's leading edge and the match (inclusive of the + // match's own row), `dist_to_end` the same from the trailing edge. Widen + // that edge by `dist + 1` (through the match's row) plus a small context + // margin, so the reveal reads like the rest of the file rather than + // stopping dead on the match itself. + let dist_to_start = m.aligned_idx - start; + let dist_to_end = end - 1 - m.aligned_idx; + if dist_to_start <= dist_to_end { + view.expand_gap(key, dist_to_start + 1 + CONTEXT_LINES, 0, false); + } else { + view.expand_gap(key, 0, dist_to_end + 1 + CONTEXT_LINES, false); + } + } } } } @@ -3881,11 +4224,12 @@ impl App { } } - // ── Outline staging (CS7) ─────────────────────────────────────────────────── + // ── The outline staging verbs ─────────────────────────────────────────────── /// Whether the changeset at `cs_idx` is a committed range rather than the uncommitted /// worktree layer — the per-index counterpart to [`Self::is_committed`] (which only reads the - /// ACTIVE changeset). CS7's outline verbs need this because the acted-on row's changeset is + /// ACTIVE changeset). The outline staging verbs need this because the acted-on row's changeset + /// is /// whichever one the outline cursor rests on, not necessarily the diff's current changeset. fn is_committed_at(&self, cs_idx: usize) -> bool { self.changesets.get(cs_idx).is_some_and(|view| { @@ -3973,8 +4317,9 @@ impl App { } } - /// Footer refusal for an outline stage/discard verb — parallels [`Self::notify_combined_refusal`] - /// but for the two CS7-specific refusal reasons: `committed` (the row's changeset — or, for a + /// Footer refusal for an outline stage/discard verb — parallels [`Self::notify_unstageable_refusal`] + /// but for the two outline-staging-verbs-specific refusal reasons: `committed` (the row's + /// changeset — or, for a /// Dir row, at least one file under it — is a committed range, not the uncommitted worktree /// layer) or not (the cursor sits on a [`OutlineItem::Header`] row, which is never a target). fn notify_outline_refusal(&mut self, verb: &str, committed: bool) { @@ -4069,7 +4414,8 @@ impl App { /// The outline-facing counterpart to [`Self::run_op`]: drain `ops` through [`Self::run_ops`], /// then restore the OUTLINE cursor to (or nearest to) `identity`'s row rather - /// than a diff-pane position (CS6's [`PositionMemento`]/[`Self::restore_position`] only make + /// than a diff-pane position (staging-preserves-the-diff-position's + /// [`PositionMemento`]/[`Self::restore_position`] only make /// sense when the diff pane, not the outline, was the focused surface the op started from). /// [`Self::coordinated_refresh`] (inside `run_ops`) itself calls `sync_outline_to_current`, /// which can leave the outline cursor on a wholly unrelated row (wherever the DIFF's current @@ -4086,7 +4432,7 @@ impl App { /// Re-find `identity`'s row in the freshly rebuilt [`Self::outline_items`] and reseat /// [`OutlineState::cursor`] there; clamps into bounds instead when the row is gone (a fully - /// discarded file drops out of the combined diff — and with it its row — entirely). Does not + /// discarded file drops out of the whole diff — and with it its row — entirely). Does not /// touch [`OutlineState::focused`] — an outline-initiated op /// only ever runs while the outline already has focus, and nothing here changes that. fn restore_outline_position(&mut self, identity: &OutlineRowIdentity, pre_op_cursor: usize) { @@ -4107,7 +4453,7 @@ impl App { }); match found { Some(idx) => self.outline.cursor = idx, - // Row gone (the NORMAL outcome of a successful discard — the file left the combined + // Row gone (the NORMAL outcome of a successful discard — the file left the whole // diff and took its row with it): stay near where the user was ACTING, not wherever // the refresh's `sync_outline_to_current` just parked the cursor (the diff's current // file, unrelated to the acted-on row). `pre_op_cursor` is the acted-on row's own @@ -4119,7 +4465,7 @@ impl App { /// Reposition (never rebuild/refocus) the outline cursor onto the row matching the CURRENT /// diff changeset+file — or, if a fold hides that row, its nearest visible (collapsed) - /// ancestor instead, WITHOUT auto-expanding it (CS5, `outline-fold` — preserves the user's + /// ancestor instead, WITHOUT auto-expanding it (`outline-fold` — preserves the user's /// fold intent; see [`Self::outline_target_index`]) — or clamps into bounds if no such row /// exists in the FULL build at all (e.g. Flat mode deduped the current file's changeset out /// of the list entirely). The sync-follow discipline's echo break: called ONLY from the @@ -4201,7 +4547,8 @@ impl App { } /// Re-derive the UNFOCUSED split pane's scroll against its own cursor, row count, and - /// [`Self::alt_height`]. Test-only since the wheel's peek model (CS10): the renderer now + /// [`Self::alt_height`]. Test-only since the wheel's peek model (mouse support): the renderer + /// now /// bounds-clamps instead of deriving (see [`Self::clamp_alt_scroll`]), and no production /// path derives the unfocused pane's scroll — the pair re-derives naturally once focus /// swaps back onto it and a cursor op runs. @@ -4214,7 +4561,8 @@ impl App { } /// Bounds-only clamp of the focused pane's scroll — the renderer's per-frame check under - /// the wheel's peek model (CS10). Unlike [`Self::derive_scroll`] it does NOT follow the + /// the wheel's peek model (mouse support). Unlike [`Self::derive_scroll`] it does NOT follow + /// the /// cursor, so a wheel-scrolled viewport (cursor possibly outside it) survives frames; it /// only keeps `scroll` inside the row list when a resize/zoom shrinks it. pub(crate) fn clamp_scroll(&mut self) { @@ -4254,7 +4602,8 @@ impl App { } /// The widest display-column row currently in the active file's view(s) — both roles when - /// split, since [`Self::hscroll`] pans every content pane together (locked decision #1). + /// split, since [`Self::hscroll`] pans every content pane together (one pan offset shared + /// by every content pane). /// Walks the already-built [`FileView::display`] row list (shared by both the SBS and inline /// layouts — inline just re-derives its own row list from the same text), so this is a pure /// lookup over rows the renderer rebuilds every frame anyway, not a fresh scan of the file. @@ -4285,7 +4634,8 @@ impl App { } /// Clamp [`Self::hscroll`] into `[0, max_row_width().saturating_sub(1)]` — the `-1` keeps at - /// least one column of the longest line visible (locked decision #4) rather than letting the + /// least one column of the longest line visible (the clamp keeps one column of the longest + /// line visible) rather than letting the /// pan run all the way to a blank viewport. fn clamp_hscroll(&mut self) { let max = self.max_row_width().saturating_sub(1); @@ -4339,19 +4689,19 @@ impl App { } /// The `(scroll, cursor)` a split pane renders with: the focused pane contributes its own - /// `scroll`/`cursor`; the unfocused pane contributes its stashed `alt` scroll/cursor (CS1, - /// `unfocused-cursor-wash` — previously `None`, since only the focused pane ever drew a + /// `scroll`/`cursor`; the unfocused pane contributes its stashed `alt` scroll/cursor + /// (`unfocused-cursor-wash` — previously `None`, since only the focused pane ever drew a /// cursor; now the unfocused half's remembered position is always returned too, so the /// renderer can paint it with the dim [`crate::theme::Palette::cursor_unfocused_bg`] wash /// when it's within the visible `scroll..end` range). The cursor alone no longer says /// whether a pane holds focus — callers resolve that separately (`split_focus_role`, - /// `outline_focused`) and pick the wash accordingly. Combined resolves to the focused + /// `outline_focused`) and pick the wash accordingly. Whole resolves to the focused /// (single) state. pub(crate) fn pane_render_state(&self, role: Role) -> (usize, Option) { let pane = match role { Role::Unstaged => SplitPane::Unstaged, Role::Staged => SplitPane::Staged, - Role::Combined => return (self.scroll, Some(self.cursor)), + Role::Whole => return (self.scroll, Some(self.cursor)), }; if self.split_focus == pane { (self.scroll, Some(self.cursor)) @@ -4427,21 +4777,23 @@ impl App { } /// Reveal more of the collapsed gap under the cursor (`Enter`), or the WHOLE gap (`E`, when - /// `full`) — CS8's progressive unfold, extended by CS9 with a two-tier `Enter`: A silent + /// `full`) — progressive gap expansion's progressive unfold, extended by tree-sitter scope + /// reveal with a two-tier `Enter`: A silent /// no-op when the cursor isn't on a `Gap` row (or there's no loaded view): unlike a staging /// refusal this isn't a mode error worth interrupting the user over, same precedent as /// [`Self::next_hunk_row`] finding no later hunk. /// - /// - `full` (`E`): unchanged from CS8 — always the flat full-run reveal via + /// - `full` (`E`): unchanged from progressive gap expansion — always the flat full-run reveal + /// via /// [`FileView::expand_gap`], regardless of grammar. - /// - `!full` (`Enter`, CS9): FIRST tries a tree-sitter scope-reveal — + /// - `!full` (`Enter`, tree-sitter scope reveal): FIRST tries a tree-sitter scope-reveal — /// [`gap_scope_start`] resolves the gap's anchor (the following row's new-side lineno, - /// preferring new like CS6's [`Self::restore_position`], old-side for delete-only files) - /// to the smallest enclosing [`crate::scope`] node, and [`FileView::scope_expand_gap`] - /// widens the gap's trailing edge to uncover it. Falls back to the flat +10/+10 reveal - /// (same as CS8) when: the file's extension has no bundled grammar, no allowlisted - /// ancestor encloses the anchor, or the scope reveals nothing new (already fully visible) - /// — so repeated `Enter` presses always widen the gap, uniformly. + /// preferring new like [`Self::restore_position`], old-side for delete-only files) to the + /// smallest enclosing [`crate::scope`] node, and [`FileView::scope_expand_gap`] widens the + /// gap's trailing edge to uncover it. Falls back to the flat +10/+10 reveal (same as + /// progressive gap expansion) when: the file's extension has no bundled grammar, no + /// allowlisted ancestor encloses the anchor, or the scope reveals nothing new (already + /// fully visible) — so repeated `Enter` presses always widen the gap, uniformly. /// /// `self.cursor`'s INDEX is left untouched either way. Rows revealed at the gap's leading /// edge insert immediately before the gap's own row (shifting the gap marker — and @@ -4490,9 +4842,9 @@ impl App { } // The expansion just reshaped the focused pane's row space — whichever tier did it — // so cancel any active selection rather than translating it, per `selection_anchor`'s - // invariant (same rule as layout toggles, zoom changes, file switches, and split-focus - // swaps). Only reached when a gap actually expanded; the non-gap no-op above leaves a - // selection alone. + // invariant (same rule as layout toggles, maximize toggles, file switches, and + // split-focus swaps). Only reached when a gap actually expanded; the non-gap no-op above + // leaves a selection alone. self.cancel_selection(); self.derive_scroll(); self.clamp_cursor(); @@ -4536,7 +4888,8 @@ impl App { /// Toggle between side-by-side and inline layouts (`L`). Deliberately does not try to /// re-derive an exactly equivalent `cursor` position for the new layout — the two layouts' /// row vectors track the same underlying content in a different shape, and translating - /// exactly isn't worth the complexity for M4; the user re-orients same as they would after a + /// exactly isn't worth the complexity for the staging-verbs work; the user re-orients same as + /// they would after a /// resize. It DOES clamp `cursor` to the new layout's `row_count()` (see /// [`Self::clamp_cursor`]) and re-derive `scroll` from it, so the result is always a valid, /// visible position even though it isn't a semantic equivalent of the old one. @@ -4547,7 +4900,8 @@ impl App { }; // The two layouts' row vectors are different coordinate spaces (a paired del/add is one // SBS row but two inline rows), so a selection anchor doesn't translate — cancel it, the - // simplest defensible choice (locked decision #8's "press L for per-side precision" flow + // simplest defensible choice (the locked decision that line selection works in both + // layouts — SBS row-pair, inline one-sided — so "press L for per-side precision" flow // starts a fresh selection anyway). self.selection_anchor = None; self.clamp_cursor(); @@ -4564,43 +4918,50 @@ impl App { }; } self.derive_scroll(); - // M11 CS3: named as a recompute trigger by the plan even though the match ADDRESSES - // (aligned-space) can't actually change here — only which display/inline row each one - // resolves to. Cheap to re-run regardless (see [`Self::recompute_search`]'s doc comment). - self.recompute_search(); + // The in-diff search: the match ADDRESSES (aligned-space) can't actually change here — + // only which display/inline row each one resolves to — so carry `search_current` across + // rather than + // losing the "you are on match N" highlight to a same-file layout flip. See + // [`Self::recompute_search_keep_current`]'s doc comment. + self.recompute_search_keep_current(); } - /// Set the render layout directly — the config-startup (CS7) counterpart to + /// Set the render layout directly — the config-startup (view-config settings) counterpart to /// [`Self::toggle_layout`]. Called before the first [`Self::open_current`], whose /// `reset_panes` derives `cursor`/`scroll` fresh for whichever layout is active, so — /// unlike `toggle_layout`, which must clamp an EXISTING cursor into the new layout's row /// count — no separate clamp is needed here. Does NOT call `open_current` itself — the - /// caller applies every CS7 setting first, then opens once. + /// caller applies every view-config setting first, then opens once. pub fn set_layout(&mut self, layout: Layout) { self.layout = layout; } - /// Set `workon.review.diff.text`'s resolved mode directly — the config-startup (CS11) - /// counterpart, mirroring [`Self::set_layout`]/[`Self::set_zoom`]. Purely a render-time - /// foreground selector: no cursor/scroll state depends on it, so unlike `set_layout` there is - /// nothing else to clamp or re-derive, at startup OR on reload. + /// Set `workon.review.diff.text`'s resolved mode directly — the config-startup (diff + /// foreground/background split) + /// counterpart, mirroring [`Self::set_layout`]. Purely a render-time foreground selector: no + /// cursor/scroll state depends on it, so unlike `set_layout` there is nothing else to clamp + /// or re-derive, at startup OR on reload. pub fn set_diff_text(&mut self, mode: DiffTextMode) { self.diff_text = mode; } - /// Apply `workon.review.outline.width|mode` and `workon.review.diff.layout|zoom|text` (CS7, - /// CS11) as the App's initial view-config state, via the same setters the interactive keys - /// drive + /// Apply `workon.review.outline.width|mode` and `workon.review.diff.layout|text` (the + /// view-config settings, the diff foreground/background split) as the App's initial + /// view-config state, via the same setters the interactive keys drive /// (see each setter's doc comment for why that's enough to stay on the gated path). Call /// once, right after construction and before [`Self::open_current`] (see `main.rs`) — the /// setters here don't themselves re-derive `cursor`/`scroll`, and the caller's - /// `open_current` is what does that for whichever settings just landed. + /// `open_current` is what does that for whichever settings just landed. `maximize` has no + /// config surface (ADR-038, "Remove `workon.review.diff.zoom`", same as `split_focus`) — + /// it's a transient view action, + /// not a startup preference, so there is no setting to apply here. /// /// `raw` is read via [`crate::config::ReviewConfig::view_config`] BEFORE `repo` moves into /// `App` (see `main.rs`) — its fields already collapsed an unset setting and a config-read - /// error to the same `None` (CS7 applies the current hardcoded default for either case, no + /// error to the same `None` (the view-config settings apply the current hardcoded default + /// for either case, no /// warning). Each setting additionally falls back to the default when SET but invalid — out - /// of range (width), or an unrecognized string (mode/layout/zoom) — collecting a warning for + /// of range (width), or an unrecognized string (mode/layout) — collecting a warning for /// those cases, same non-fatal posture as the keymap/theme resolution (ADR-034). pub fn apply_view_config(&mut self, raw: &RawViewConfig) -> Vec { let mut warnings = Vec::new(); @@ -4660,17 +5021,6 @@ impl App { }; self.set_layout(layout); - let zoom = match &raw.diff_zoom { - Some(z) => resolve_option( - "workon.review.diff.zoom", - z, - DIFF_ZOOM_OPTIONS, - &mut warnings, - ), - None => Zoom::default(), - }; - self.set_zoom(zoom); - let diff_text = match &raw.diff_text { Some(t) => resolve_option( "workon.review.diff.text", @@ -4696,13 +5046,9 @@ impl App { /// Instead: run `apply_view_config`, then replay only the TAIL of whichever interactive /// counterpart(s) actually changed something — [`Self::toggle_layout`]'s tail if `layout` /// flipped, [`Self::outline_cycle_mode`]'s tail if `outline.mode`/`outline.order` changed. - /// `zoom`'s interactive counterpart, [`Self::cycle_zoom`], has no further tail beyond the bare - /// assignment once its committed-changeset notice is dropped — that notice was purely - /// interactive feedback for what would otherwise be a silent cycle no-op, not an invariant: - /// [`Self::effective_zoom_for`] already collapses a non-stageable changeset to `Combined` - /// regardless of the requested zoom, so a config-driven `zoom` change can't bypass the gate - /// either. Reload never emits that notice and never re-derives the pane position for a zoom - /// change — same "don't call `open_current`" reasoning as everything else here. + /// `maximize` has no config surface at all (ADR-038, "Remove `workon.review.diff.zoom`") + /// — `apply_view_config` never + /// touches it, so there is no tail to replay for it here. pub fn reload_view_config(&mut self, raw: &RawViewConfig) -> Vec { let layout_before = self.layout; let outline_mode_before = self.outline.mode; @@ -4725,7 +5071,9 @@ impl App { }; } self.derive_scroll(); - self.recompute_search(); + // Mirrors `toggle_layout`'s tail: same-file layout flip, so carry `search_current` + // across rather than losing it (see [`Self::recompute_search_keep_current`]). + self.recompute_search_keep_current(); } if self.outline.mode != outline_mode_before || self.outline.order != outline_order_before { @@ -4764,52 +5112,67 @@ impl App { } } + /// Whether a staging verb would act on the current file rather than refuse — the same + /// [`Self::staging_role`] gate `stage_file`/`stage_hunk`/`start_selection` check before they + /// call [`Self::notify_unstageable_refusal`], read here by the renderer so the footer stops + /// advertising `stage`/`discard` where they can only refuse (`render::render_footer`). + /// + /// Deliberately the SAME predicate rather than a second one that reconstructs the conditions + /// (committed changeset, binary file, empty file list): a separate copy would drift, and the + /// footer would then either hide a key that works or advertise one that doesn't. + pub fn can_stage_current(&self) -> bool { + !self.cur().diff.files.is_empty() && self.staging_role().is_some() + } + /// The role a staging verb acts in for the current file: the single effective role, or the - /// focused split pane's role. `None` for [`Role::Combined`] — the combined view fuses both + /// focused split pane's role. `None` for [`Role::Whole`] — the whole role fuses both /// sub-diffs, so staging there has no unambiguous direction and the verbs refuse (locked - /// decision #1). + /// decision: verbs act only in the unstaged/staged panes; direction = pane role). fn staging_role(&self) -> Option { match self.effective_zoom_for(self.current) { - EffectiveZoom::Single(Role::Combined) => None, + EffectiveZoom::Single(Role::Whole) => None, EffectiveZoom::Single(role) => Some(role), EffectiveZoom::Split => Some(self.split_focus_role()), } } - /// Toggle-direction by role (locked decision #1): the unstaged pane stages, the staged pane - /// unstages. `None` for [`Role::Combined`] (never a staging target). + /// Toggle-direction by role (verbs act only in the unstaged/staged panes; direction = + /// pane role): the unstaged pane stages, the staged pane + /// unstages. `None` for [`Role::Whole`] (never a staging target). fn verb_for_role(role: Role) -> Option { match role { Role::Unstaged => Some(StageVerb::Stage), Role::Staged => Some(StageVerb::Unstage), - Role::Combined => None, + Role::Whole => None, } } /// Mode-aware refusal notice for a staging verb / line-selection start that only makes sense - /// outside the combined view — i.e. every call site below whose `staging_role()`/ - /// `staging_role().is_none()` guard failed (locked decision #2's "targeted guard"). A - /// committed changeset is ALWAYS combined-only (no staged/unstaged split exists to zoom - /// into — see [`Self::is_committed`]), so telling the user to "cycle zoom" there is actively - /// wrong; state the real reason instead. `verb` ("stage"/"select") keeps each call site's - /// original non-committed wording. - fn notify_combined_refusal(&mut self, verb: &str) { + /// outside the whole role — i.e. every call site below whose `staging_role()`/ + /// `staging_role().is_none()` guard failed (the locked decision that committed mode is + /// derived, not stored, with targeted guards). A + /// committed changeset is ALWAYS whole-only (no staged/unstaged split exists — see + /// [`Self::is_committed`]), so it gets its own wording. The non-committed branch's only + /// remaining caller is a binary file (ADR-038 decision 10): `effective_zoom` short-circuits + /// on `!can_stage` before it looks at anything else, so no key press moves it out of + /// `Role::Whole` — advising a key would be wrong, so this states non-stageability instead. + /// `verb` ("stage"/"select") keeps each call site's original non-committed wording. + fn notify_unstageable_refusal(&mut self, verb: &str) { if self.is_committed() { self.notify( "changeset is already committed — nothing to stage", Severity::Error, ); } else { - let key = &self.zoom_key_label; self.notify( - format!("{verb} in the unstaged/staged pane — cycle zoom ({key})"), + format!("{verb} refused — file is not stageable"), Severity::Error, ); } } /// Stage (unstaged pane) or unstage (staged pane) the hunk under the cursor (`s`). Refuses on - /// the combined view, or when the cursor isn't in a hunk. + /// the whole role, or when the cursor isn't in a hunk. /// /// When a line selection is active, `s` acts on the SELECTION instead /// ([`Self::stage_selection`]) — the hunk under the cursor is irrelevant once the user has @@ -4823,7 +5186,7 @@ impl App { return; } let Some(role) = self.staging_role() else { - self.notify_combined_refusal("stage"); + self.notify_unstageable_refusal("stage"); return; }; let Some(verb) = Self::verb_for_role(role) else { @@ -4834,7 +5197,7 @@ impl App { return; }; // The hunk index is into the ROLE's own hunks, so the op must apply against that role's - // sub-`FileChange`, not the combined one. + // sub-`FileChange`, not the whole one. let Some(file) = self.role_change(self.current, role).cloned() else { return; }; @@ -4842,26 +5205,26 @@ impl App { } /// Stage (unstaged pane) or unstage (staged pane) the whole current file (`S`) — ignores the - /// cursor. Refuses on the combined view. + /// cursor. Refuses on the whole role. pub fn stage_file(&mut self) { if self.cur().diff.files.is_empty() { return; } let Some(role) = self.staging_role() else { - self.notify_combined_refusal("stage"); + self.notify_unstageable_refusal("stage"); return; }; let Some(verb) = Self::verb_for_role(role) else { return; }; // A whole-file op routes on path + status only ([`crate::ops::apply_file`]), which the - // combined file carries authoritatively (e.g. Untracked-ness for a discard). + // whole file carries authoritatively (e.g. Untracked-ness for a discard). let file = self.cur().diff.files[self.current].clone(); self.run_op(FileStagingOp::file(file, verb)); } /// Request confirmation to discard the hunk under the cursor from the worktree (`d`). Refuses - /// on the combined view, in a staged pane (discard only reverts worktree changes), or when the + /// on the whole role, in a staged pane (discard only reverts worktree changes), or when the /// cursor isn't in a hunk. The discard itself runs when the user answers `y`. /// /// When a line selection is active, `d` acts on the SELECTION instead @@ -4875,7 +5238,7 @@ impl App { return; } let Some(role) = self.staging_role() else { - self.notify_combined_refusal("stage"); + self.notify_unstageable_refusal("stage"); return; }; if role != Role::Unstaged { @@ -4896,13 +5259,13 @@ impl App { } /// Request confirmation to discard the whole current file's worktree changes (`D`). Refuses on - /// the combined view or in a staged pane; the discard runs on `y`. + /// the whole role or in a staged pane; the discard runs on `y`. pub fn discard_file(&mut self) { if self.cur().diff.files.is_empty() { return; } let Some(role) = self.staging_role() else { - self.notify_combined_refusal("stage"); + self.notify_unstageable_refusal("stage"); return; }; if role != Role::Unstaged { @@ -4987,14 +5350,16 @@ impl App { /// Enqueue `op`, drain the queue on the same beat, then act on the outcome: a failure or panic /// surfaces on the footer (and the views still refresh — see [`Self::run_ops`] for why); a - /// `Completed` drain refreshes, rebuilding the views + attribution from the new index (locked - /// decision #5), then restores the reviewer's pre-op DIFF position (CS6) — a staging op is + /// `Completed` drain refreshes, rebuilding the views + attribution from the new index + /// (locked decision: the queue enqueues and drains in the same beat), then restores the + /// reviewer's pre-op DIFF position (staging preserves the diff position) — a staging op is /// the ONE nav path that does not reset to the role's first hunk; every manual nav still /// does, via `reset_panes` unchanged. /// /// A thin diff-facing wrapper over [`Self::run_ops`] (one op, one memento) — the diff pane's /// staging verbs (`s`/`S`/`d`/`D`) are the only callers, so the shared drain/refresh core - /// lives on `run_ops` and this just supplies the diff-position memento CS7's outline verbs + /// lives on `run_ops` and this just supplies the diff-position memento the outline staging + /// verbs /// don't want (see [`Self::outline_run_ops`], which restores the OUTLINE cursor instead). fn run_op(&mut self, op: impl StagingOp + 'static) { let memento = self.capture_position(); @@ -5015,7 +5380,8 @@ impl App { /// Generic over any [`StagingOp`] — a hunk/file op ([`FileStagingOp`]), a (possibly /// multi-hunk) line selection ([`LineSelectionOp`], which applies as ONE merged patch rather /// than enqueueing one op per hunk — see that type's docs for why splitting is wrong), or - /// (CS7) several independent whole-file ops from an outline Dir row. The queue's trap-4 + /// (the outline staging verbs) several independent whole-file ops from an outline Dir + /// row. The queue's live-index staging queue /// live-index staleness doesn't apply here: every op resolves its own direction from the live /// index inside `run` (see `queue.rs`'s module doc), so draining several back-to-back is safe. fn run_ops(&mut self, ops: Vec>) -> Result<(), ()> { @@ -5046,8 +5412,9 @@ impl App { } /// Snapshot the focused pane's file/role/position ahead of a staging op, for - /// [`Self::restore_position`] to reseat after the op's `coordinated_refresh` (CS6). `None` - /// when there's no current file, the current view is the combined role (never a staging + /// [`Self::restore_position`] to reseat after the op's `coordinated_refresh` (staging + /// preserves the diff position). `None` + /// when there's no current file, the current view is the whole role (never a staging /// target — [`Self::staging_role`]), or the focused role's view isn't loaded; restore is then /// a no-op and today's `reset_panes` first-hunk behavior stands. fn capture_position(&self) -> Option { @@ -5077,7 +5444,8 @@ impl App { } /// Reseat the focused pane to a pre-staging-op position after `coordinated_refresh` rebuilds - /// the views (CS6) — the staging-path counterpart to `reset_panes`' first-hunk reseat, which + /// the views (staging preserves the diff position) — the staging-path counterpart to + /// `reset_panes`' first-hunk reseat, which /// this deliberately leaves untouched for every manual nav (file/changeset switch, zoom /// cycle). Falls back to whatever `reset_panes` already produced (today's first-hunk /// behavior) when the acted-on file's path is gone (fully discarded) or its memento carried @@ -5111,7 +5479,7 @@ impl App { }; // The memento's linenos were captured in `m.role`'s own frame (new = worktree for - // Unstaged/Combined, new = index for Staged — see `FileView::load`'s table). Preferring + // Unstaged/Whole, new = index for Staged — see `FileView::load`'s table). Preferring // new over old is correct BOTH when the role is unchanged (the common case: same pane, // same frame) AND on the one role change that can happen here — unstaged -> staged after // fully staging a file in Split. In that case the staged view's new side (index) now @@ -5134,14 +5502,14 @@ impl App { } /// Start a line selection anchored at the current cursor (`v`). Refuses (a notice, no anchor - /// set) on the combined view or any non-staging role — you can only select lines where you can + /// set) on the whole role or any non-staging role — you can only select lines where you can /// stage them (same gate as the verbs). A no-op on an empty file list. pub fn start_selection(&mut self) { if self.cur().diff.files.is_empty() { return; } if self.staging_role().is_none() { - self.notify_combined_refusal("select"); + self.notify_unstageable_refusal("select"); return; } self.selection_anchor = Some(self.cursor); @@ -5166,7 +5534,8 @@ impl App { /// grazed by only context/gap rows is dropped). Empty when there's no selection, no loaded /// focused view, or the range covers only context. /// - /// The two layouts differ in what a selected row contributes (locked decision #8): + /// The two layouts differ in what a selected row contributes (the locked decision that + /// line selection works in both layouts — SBS row-pair, inline one-sided): /// - **SBS** row-pair semantics: a selected `AlignedRow` keeps BOTH sides it changes — its Del /// cell's old line and its Add cell's new line — because a side-by-side row can't split a /// paired edit (per-side precision is what inline is for). @@ -5250,7 +5619,7 @@ impl App { } /// Stage (unstaged pane) / unstage (staged pane) the active line selection (`s` with a - /// selection up). Refuses on the combined view (cycle-zoom notice), on a file no line op can + /// selection up). Refuses on the whole role (cycle-zoom notice), on a file no line op can /// express ([`ops::supports_line_ops`] — Deleted/Unmerged/binary, per-status notice), and on /// a selection that covers no changed lines. Otherwise applies every overlapped hunk's kept /// lines as ONE merged patch via [`LineSelectionOp`] (never one op per hunk — see that @@ -5261,7 +5630,7 @@ impl App { return; } let Some(role) = self.staging_role() else { - self.notify_combined_refusal("stage"); + self.notify_unstageable_refusal("stage"); return; }; let Some(verb) = Self::verb_for_role(role) else { @@ -5300,7 +5669,7 @@ impl App { return; } let Some(role) = self.staging_role() else { - self.notify_combined_refusal("stage"); + self.notify_unstageable_refusal("stage"); return; }; if role != Role::Unstaged { @@ -5437,9 +5806,9 @@ impl From for DiffState { let WorktreeDiffs { staged, unstaged, - combined, + whole, } = diffs; - let files = combined.files; + let files = whole.files; let unstaged_idx = files .iter() .map(|f| find_role_change(&unstaged, f)) @@ -5474,8 +5843,9 @@ impl DiffState { /// diffed by [`crate::acquire::diff_committed`]) — there is no staged/unstaged split for a /// committed range, so both sub-models are empty and every index map entry is `None`. This /// alone is enough to render the changeset read-only: [`effective_zoom`] collapses - /// `Split`/`Unstaged`/`Staged` to [`EffectiveZoom::Single(Role::Combined)`] whenever both - /// sub-diffs are absent, so no committed-specific rendering path is needed for M5's spine. + /// `Split`/`Unstaged`/`Staged` to [`EffectiveZoom::Single(Role::Whole)`] whenever both + /// sub-diffs are absent, so no committed-specific rendering path is needed for the + /// stack-and-outline work's spine. fn from_committed(model: DiffModel) -> Self { let n = model.files.len(); Self { @@ -5488,7 +5858,8 @@ impl DiffState { } } -/// Index of the changeset the lib marked `current` (locked decision #6), or `0` if none is — +/// Index of the changeset the lib marked `current` (locked decision: open on whichever +/// changeset the lib marks current), or `0` if none is — /// the shared rule [`App::from_changesets`] uses to open, and [`App::refresh`] falls back to /// when the previously-active changeset's name no longer exists after a re-assembly. fn current_cs_index(changesets: &[ChangesetView]) -> usize { @@ -5520,7 +5891,7 @@ fn outline_row_identity(item: &OutlineItem) -> Option<(usize, Option)> { #[derive(Debug, Clone)] pub struct FileLoadSpec { span: ChangesetSpan, - combined_file: FileChange, + whole_file: FileChange, /// The [`EffectiveZoom`] `App` had AT DISPATCH TIME — the views built are shaped by this, /// not by whatever `App`'s zoom/current file happen to be when the result lands (which may /// have changed by then; that's fine, see the ADR's "Generations": within a generation, a @@ -5562,10 +5933,10 @@ fn loaded_views_satisfy(views: &LoadedViews, current_zoom: EffectiveZoom) -> boo /// Build every [`FileView`] a [`FileLoadSpec`] needs, against `repo`/`ts` — the ADR-037 loader /// thread's pure job body: unit-testable directly against a fixture repo, no threads or channels -/// involved. Routes through the SAME [`build_combined_view`]/[`build_sub_role_view`] free +/// involved. Routes through the SAME [`build_whole_view`]/[`build_sub_role_view`] free /// functions [`App::ensure_role_loaded`] calls, so a deferred-then-loader-completed open is /// byte-identical to an eager [`App::open_current`] — the invariant ADR-037 carries over from -/// CS4's `complete_pending_open`. +/// idle-deferred file loads' `complete_pending_open`. pub fn build_file_views( repo: &Repository, ts: &mut TsHighlighter, @@ -5574,7 +5945,7 @@ pub fn build_file_views( match spec.zoom { EffectiveZoom::Single(role) => { let view = match role { - Role::Combined => build_combined_view(repo, ts, spec.span, &spec.combined_file), + Role::Whole => build_whole_view(repo, ts, spec.span, &spec.whole_file), Role::Unstaged => spec .unstaged_file .as_ref() @@ -5610,7 +5981,7 @@ pub fn build_file_views( fn set_if_absent(cs: &mut ChangesetView, role: Role, idx: usize, view: Option>) { let view = view.map(|boxed| *boxed); let slots = match role { - Role::Combined => &mut cs.views_combined, + Role::Whole => &mut cs.views_whole, Role::Unstaged => &mut cs.views_unstaged, Role::Staged => &mut cs.views_staged, }; @@ -5623,7 +5994,7 @@ fn set_if_absent(cs: &mut ChangesetView, role: Role, idx: usize, view: Option String { match cs.span { ChangesetSpan::Committed { base, .. } => { @@ -5636,10 +6007,10 @@ fn base_label_for(cs: &Changeset) -> String { } } -/// Index of the [`FileChange`] in a role's [`DiffModel`] that corresponds to combined `file`, or +/// Index of the [`FileChange`] in a role's [`DiffModel`] that corresponds to whole `file`, or /// `None` when the role has no change for it (e.g. an untracked file in the staged model). /// -/// Matches by `path` (the common case), with rename-aware fallbacks: the combined and sub-diffs +/// Matches by `path` (the common case), with rename-aware fallbacks: the whole and sub-diffs /// agree on a rename's new `path`, but a file renamed in only one role can leave the match to /// `old_path` on either side. Path equality wins for the overwhelming majority; the fallbacks just /// avoid dropping the odd asymmetric-rename pairing. @@ -5693,8 +6064,9 @@ fn row_lineno(row: Row) -> Option { } /// The (old, new) 1-based line numbers a display row occupies — `None` on a filler side, and -/// `(None, None)` for a gap row (which belongs to no hunk). `pub(crate)`: `render.rs`'s M11 CS3 -/// search-highlight lookup reuses this exact pairing (the same key [`crate::search::SearchMatch`] +/// `(None, None)` for a gap row (which belongs to no hunk). `pub(crate)`: `render.rs`'s +/// in-diff-search-highlight lookup reuses this exact pairing (the same key +/// [`crate::search::SearchMatch`] /// carries) rather than re-deriving its own. pub(crate) fn display_row_linenos(row: &DisplayRow) -> (Option, Option) { match row { @@ -5703,11 +6075,12 @@ pub(crate) fn display_row_linenos(row: &DisplayRow) -> (Option, Option Combined - assert_eq!(app.zoom, Zoom::Combined); + // Persists across file navigation, like layout — and (ADR-038, "`reset_panes` + // preserves `split_focus` when `maximized` is set") so does focus + // while maximized: maximize the STAGED pane, navigate away and back, and confirm both + // survive — the case the old zoom-cycling test couldn't express. + app.toggle_split_focus(); // -> Staged pane + assert_eq!(app.split_focus, super::SplitPane::Staged); + app.toggle_maximize(); // -> maximized on the staged pane + assert!(app.maximized); app.next_file(); + assert!(app.maximized, "maximize must persist across next_file"); assert_eq!( - app.zoom, - Zoom::Combined, - "zoom must persist across next_file" + app.split_focus, + super::SplitPane::Staged, + "focus must persist across next_file while maximized (reset_panes preserves \ + split_focus when maximized is set)" ); app.prev_file(); - assert_eq!(app.zoom, Zoom::Combined, "and across prev_file"); + assert!(app.maximized, "and across prev_file"); + assert_eq!(app.split_focus, super::SplitPane::Staged, "and focus too"); } #[test] @@ -7116,7 +7503,7 @@ mod tests { assert!(app.notice.is_none(), "clear_notice must clear a set notice"); } - // ---- M4 refresh: in-place re-diff + rebuild ------------------------------------------- + // ---- Staging verbs refresh: in-place re-diff + rebuild ---------------------------------- #[test] fn refresh_after_external_worktree_edit_picks_up_the_change() { @@ -7242,8 +7629,8 @@ mod tests { } #[test] - fn refresh_preserves_zoom_and_layout() { - use super::{Layout, Zoom}; + fn refresh_preserves_maximize_and_layout() { + use super::Layout; let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") @@ -7254,15 +7641,16 @@ mod tests { let mut app = app_from_fixture(&fixture); app.open_current(); app.layout = Layout::Inline; - app.zoom = Zoom::Combined; + app.maximized = true; app.refresh(); assert_eq!(app.layout, Layout::Inline, "refresh must not reset layout"); - assert_eq!(app.zoom, Zoom::Combined, "refresh must not reset zoom"); + assert!(app.maximized, "refresh must not reset maximize"); } - /// M7 CS2 fix: a session launched with an explicit `[SOURCE]` argument must have `refresh` + /// A stack/uncommitted-source-keywords fix: a session launched with an explicit `[SOURCE]` + /// argument must have `refresh` /// re-resolve THAT source, never silently downgrade to no-argument auto-detect. A Graphite /// stack is active (`assemble_changesets` would return the whole `a`/`b` stack for /// auto-detect), but the session was launched with `uncommitted` — so both the manual `r` @@ -7371,6 +7759,36 @@ mod tests { ); } + // ---- stale-diff alignment crash: workdir races diff acquisition ---------------------- + + /// The confirmed repro (2026-07-27 handoff): `file.hunks` are diffed against the workdir + /// state as it stood at diff-acquisition time, but `FileView::load`'s new-side text for + /// `Role::Unstaged`/`Whole` is a LIVE workdir read (see its role table) — if the file grows + /// on disk in between (an editor or agent writing to it while the TUI sits idle), the hunk + /// geometry and the freshly-read line count describe different revisions of the same file. + /// Before the fix this panicked the `debug_assert_eq!` in `align.rs`'s tail-gap clamp + /// (`left: 0, right: 3`, `trailing context after the last hunk must be equal length on both + /// sides`). Part 1 makes `align_file` tolerant instead of asserting; Part 2 detects the + /// mismatch and re-diffs once to correct it — this test only pins that the load survives. + /// + /// Note the asymmetry the handoff calls out: the file GROWING reproduces this; the same + /// fixture with the workdir SHRUNK does not (the mismatch only escapes the pre-fix `.min()` + /// clamp in one direction) — this test deliberately only covers the growing case. + #[test] + fn workdir_growing_after_diff_acquisition_does_not_crash_the_load() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "a\nb\nc\nd\ne\n", "a\nB\nc\nd\ne\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + // Diffs are already acquired. Now the file grows on disk, as it would if an editor or an + // agent wrote to it while the TUI sat idle. + let workdir = fixture.repo().unwrap().workdir().unwrap().to_path_buf(); + std::fs::write(workdir.join("f.txt"), "a\nB\nc\nd\ne\nf\ng\nh\n").unwrap(); + app.open_current(); + } + // ---- ADR-037 refresh: span-keyed reuse, uncommitted always sync, async waves ---------- /// Build a two-commit chain (`root` then `head`) on the fixture's default branch and return @@ -7426,7 +7844,7 @@ mod tests { assert_eq!(app.files().len(), 2, "root..head touches a.txt and b.txt"); app.next_file(); // caches file 1 ("b.txt") too app.prev_file(); // back to file 0 — the file `refresh`'s tail will re-seat - assert!(app.role_view_ref(1, Role::Combined).is_some()); + assert!(app.role_view_ref(1, Role::Whole).is_some()); let gen_before = app.generation(); app.refresh(); @@ -7443,7 +7861,7 @@ mod tests { assert_eq!(app.files().len(), 2); assert_eq!(app.current, 0, "file position by path is preserved"); assert!( - app.role_view_ref(1, Role::Combined).is_some(), + app.role_view_ref(1, Role::Whole).is_some(), "file 1's view cache must survive untouched — refresh's tail only (re)opens the \ CURRENT file (0), so a still-populated cache at 1 proves the whole ChangesetView \ (not just its diff) was carried over rather than rebuilt fresh" @@ -7785,7 +8203,7 @@ mod tests { assert!(app.take_pending_wave().is_none()); } - // ---- M4 index watcher (`on_tick`) ------------------------------------------------------- + // ---- Staging-verbs index watcher (`on_tick`) -------------------------------------------- /// Stage `path` in the fixture's index, exactly as an external `git add` would — the write /// [`App::on_tick`] is meant to notice, since [`crate::refresh::IndexSignature`] only @@ -7912,7 +8330,7 @@ mod tests { ); } - // ---- M4 staging: hunk identity --------------------------------------------------------- + // ---- Staging verbs: hunk identity ------------------------------------------------------- /// A modified file whose only two changes are its first and last line, with a dozen unchanged /// lines between — so the two hunks are far enough apart to leave a collapsed gap between @@ -8006,10 +8424,10 @@ mod tests { assert_eq!(app.hunk_at_cursor(), None); } - // ---- M4 staging: verbs ----------------------------------------------------------------- + // ---- Staging verbs: verbs -------------------------------------------------------------- /// A file with three distinct HEAD/index/worktree states — both a staged and an unstaged - /// sub-diff, and hunk-patchable (Modified). Same shape the zoom tests use. + /// sub-diff, and hunk-patchable (Modified). Same shape the split/maximize gate tests use. fn partial_fixture() -> Fixture { FixtureBuilder::new() .config("core.autocrlf", "false") @@ -8046,12 +8464,12 @@ mod tests { #[test] fn stage_hunk_in_staged_pane_unstages_the_hunk() { - use super::Zoom; - let fixture = partial_fixture(); let mut app = app_from_fixture(&fixture); - app.zoom = Zoom::Staged; - app.open_current(); + // The file has both sub-diffs, so the default gate is Split — maximize the staged pane + // to force a single Staged-role pane (ADR-038; the old test forced this via `Zoom::Staged`). + app.toggle_split_focus(); // -> Staged pane + app.toggle_maximize(); // -> maximized on the staged pane app.stage_hunk(); // staged pane → unstage direction // Unstaging the only staged hunk reverts the index entry to HEAD. @@ -8078,11 +8496,59 @@ mod tests { repo.assert(predicate::repo::has_staged_file("a.txt")); } + /// The ADR-037 loader thread holds ONE `Repository` for the whole session + /// (`tui.rs`'s `spawn_loader_thread`), and libgit2 caches a repository's index in memory + /// without ever re-reading it from disk. So once any load has primed that handle's index, + /// every later `read_index_blob` on it returns the index as it was BEFORE the main thread's + /// staging op — the staged view's new side comes back short, and every row past the stale + /// blob's last line renders its gutter with no text. + /// + /// Drives the real sequence synchronously: defer, dispatch, `build_file_views` on a SECOND + /// handle, seat the result — the shape `tui.rs`'s event loop runs (see its own + /// `run_load_job` tests), with one persistent loader handle across both loads. #[test] - fn stage_file_in_staged_pane_unstages_whole_file() { - use super::Zoom; - - // A freshly `git add`ed (Added) file has only a staged sub-diff. + fn a_deferred_load_on_a_reused_loader_handle_sees_the_staged_index() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "a\nb\nc\n", "a\nb\nc\nd\ne\n") + .build() + .unwrap(); + let workdir = fixture.repo().unwrap().workdir().unwrap().to_path_buf(); + + // The loader thread's single, long-lived handle. + let loader_repo = Repository::open(&workdir).unwrap(); + let mut loader_ts = crate::highlight::TsHighlighter::new(); + let mut pump = |app: &mut App| { + if let Some((gen, cs_idx, file_idx, spec)) = app.take_pending_load_spec() { + let views = build_file_views(&loader_repo, &mut loader_ts, &spec); + app.apply_file_ready(gen, cs_idx, file_idx, Ok(views)); + } + }; + + let mut app = app_from_fixture(&fixture); + app.set_defer_loads(true); + app.open_current(); + pump(&mut app); // primes the loader handle's index cache (unstaged old side) + + app.focus_outline(); + app.outline_stage(); // whole-file stage from the outline: no sync force-completion + pump(&mut app); + + let view = app + .role_view_ref(0, Role::Staged) + .expect("the staged view must be seated"); + assert_eq!( + view.new_text(), + "a\nb\nc\nd\ne\n", + "the staged view's new side must read the POST-stage index, not the loader \ + handle's cached pre-stage copy" + ); + } + + #[test] + fn stage_file_in_staged_pane_unstages_whole_file() { + // A freshly `git add`ed (Added) file has only a staged sub-diff — the default gate + // already collapses to Single(Staged), nothing to force. let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .staged_file("new.txt", "hello\n") @@ -8090,7 +8556,6 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); - app.zoom = Zoom::Staged; app.open_current(); app.stage_file(); // staged pane → unstage; Added file has no HEAD entry, so it goes untracked @@ -8098,7 +8563,7 @@ mod tests { repo.assert(predicate::repo::has_untracked_file("new.txt")); } - // ---- CS6: staging preserves diff position ---------------------------------------------- + // ---- Staging preserves the diff position ------------------------------------------------ /// Three single-line edits well-separated (>6 lines of pure context apart, git's own /// hunk-splitting threshold) so each is its own hunk AND the context between any two @@ -8344,7 +8809,7 @@ mod tests { assert_eq!(app.cursor, expected); } - // ---- M4 staging: discard confirm flow -------------------------------------------------- + // ---- Staging verbs: discard confirm flow ------------------------------------------------ #[test] fn discard_hunk_requests_confirm_then_y_reverts_the_worktree() { @@ -8418,73 +8883,52 @@ mod tests { )); } - // ---- M4 staging: refusals -------------------------------------------------------------- + // ---- Staging verbs: refusals ------------------------------------------------------------ #[test] - fn stage_hunk_in_combined_view_refuses_without_touching_the_index() { - use super::{Severity, Zoom}; - - let fixture = partial_fixture(); - let mut app = app_from_fixture(&fixture); - app.zoom = Zoom::Combined; - app.open_current(); - app.stage_hunk(); + fn stage_hunk_on_a_binary_file_refuses_without_touching_the_index() { + // ADR-038, "Reword `notify_unstageable_refusal`'s non-committed branch": post-in-diff- + // navigation, a binary file is `notify_unstageable_refusal`'s only + // non-committed caller — a file with both real sub-diffs can no longer land in + // `Role::Whole` at all (maximize only narrows to the focused pane's role), so this + // re-points the old `Zoom::Combined`-forced test at the one case that still reaches it. + use super::Severity; - let notice = app.notice.as_ref().expect("combined stage must refuse"); - assert_eq!(notice.severity, Severity::Error); - assert!(notice.text.contains("cycle zoom"), "got: {:?}", notice.text); - // The index is untouched — still the originally-staged content. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("bin.dat", "hello\n") + .build() + .unwrap(); + // Overwrite the worktree copy with binary content post-build, same technique as + // `ensure_loaded_skips_binary_files` — the whole diff's content-sniffing then flags it + // binary, which forces `Role::Whole` regardless of maximize/focus. let repo = fixture.repo().unwrap(); - repo.assert(predicate::repo::index_blob_equals( - "f.txt", - "alpha\nBETAEDIT\ngamma\n", - )); - } - - #[test] - fn combined_refusal_defaults_to_the_shift_z_label() { - use super::{Severity, Zoom}; - - // `App::from_changesets`/`App::new` paths that never seat a keymap (this test included) - // must keep showing the command's default binding, byte-identical to before this field - // existed. - let fixture = partial_fixture(); - let mut app = app_from_fixture(&fixture); - app.zoom = Zoom::Combined; - app.open_current(); - app.stage_hunk(); - - let notice = app.notice.as_ref().expect("combined stage must refuse"); - assert_eq!(notice.severity, Severity::Error); - assert!(notice.text.contains("(Z)"), "got: {:?}", notice.text); - } - - #[test] - fn combined_refusal_shows_the_seated_zoom_key_label() { - use super::{Severity, Zoom}; + std::fs::write(repo.workdir().unwrap().join("bin.dat"), [0u8, 1, 2, 0, 3]).unwrap(); - // `main.rs::seat_app` calls `set_zoom_key_label` with the resolved CycleZoom binding — - // simulate a rebind by setting a non-default label directly. - let fixture = partial_fixture(); let mut app = app_from_fixture(&fixture); - app.set_zoom_key_label("F5".to_string()); - app.zoom = Zoom::Combined; + assert!(app.files()[0].is_binary); app.open_current(); app.stage_hunk(); - let notice = app.notice.as_ref().expect("combined stage must refuse"); + let notice = app.notice.as_ref().expect("binary stage must refuse"); assert_eq!(notice.severity, Severity::Error); - assert!(notice.text.contains("(F5)"), "got: {:?}", notice.text); + assert!( + notice.text.contains("not stageable"), + "got: {:?}", + notice.text + ); + // The index is untouched — still the originally-staged content. + repo.assert(predicate::repo::index_blob_equals("bin.dat", "hello\n")); } #[test] fn discard_hunk_in_staged_pane_refuses() { - use super::{Severity, Zoom}; - let fixture = partial_fixture(); let mut app = app_from_fixture(&fixture); - app.zoom = Zoom::Staged; - app.open_current(); + // The file has both sub-diffs, so the default gate is Split — maximize the staged pane + // to force a single Staged-role pane (ADR-038; the old test forced this via `Zoom::Staged`). + app.toggle_split_focus(); // -> Staged pane + app.toggle_maximize(); // -> maximized on the staged pane app.discard_hunk(); assert!( @@ -8536,7 +8980,7 @@ mod tests { ); } - // ---- M4 line selection ----------------------------------------------------------------- + // ---- Staging verbs: line selection ------------------------------------------------------- /// One hunk with two independent paired changes (line 2 `b`->`B`, line 4 `d`->`D`, one /// context line `c` between them). SBS display rows: 0 ctx, 1 del/add (b/B), 2 ctx, 3 del/add @@ -8594,7 +9038,8 @@ mod tests { assert_eq!(ops.len(), 1, "one hunk overlapped"); let (hunk_idx, sel) = &ops[0]; assert_eq!(*hunk_idx, 0); - // SBS row-pair semantics (locked decision #8): a paired row keeps BOTH sides. + // SBS row-pair semantics (line selection works in both layouts): a paired row keeps BOTH + // sides. assert_eq!(sel.keep_dels.len(), 1, "SBS keeps the row's deleted line"); assert_eq!(sel.keep_adds.len(), 1, "SBS keeps the row's added line too"); } @@ -8619,7 +9064,8 @@ mod tests { let ops = app.selection_line_ops(); assert_eq!(ops.len(), 1); let (_, sel) = &ops[0]; - // Inline keeps exactly the one side the selected row shows (locked decision #8). + // Inline keeps exactly the one side the selected row shows (line selection works in + // both layouts). assert_eq!( sel.keep_dels.len(), 1, @@ -8979,22 +9425,37 @@ mod tests { } #[test] - fn start_selection_in_combined_view_refuses() { - use super::{Severity, Zoom}; + fn start_selection_on_a_binary_file_refuses() { + // Same re-point as `stage_hunk_on_a_binary_file_refuses_without_touching_the_index`: a + // binary file is the only non-committed case left that lands in `Role::Whole`. + use super::Severity; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("bin.dat", "hello\n") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + std::fs::write(repo.workdir().unwrap().join("bin.dat"), [0u8, 1, 2, 0, 3]).unwrap(); - let fixture = partial_fixture(); let mut app = app_from_fixture(&fixture); - app.zoom = Zoom::Combined; app.open_current(); app.start_selection(); assert!( app.selection_anchor.is_none(), - "the combined view has no staging direction, so selection is refused" + "the whole role has no staging direction, so selection is refused" ); - let notice = app.notice.as_ref().expect("combined selection must refuse"); + let notice = app + .notice + .as_ref() + .expect("whole-role selection must refuse"); assert_eq!(notice.severity, Severity::Error); - assert!(notice.text.contains("cycle zoom"), "got: {:?}", notice.text); + assert!( + notice.text.contains("not stageable"), + "got: {:?}", + notice.text + ); } #[test] @@ -9021,10 +9482,10 @@ mod tests { ); } - // ── M5 CS1: the changeset-stack spine ───────────────────────────────────── + // ── The changeset-stack spine ─────────────────────────────────────────────── #[test] - fn single_uncommitted_changeset_matches_m4_shape() { + fn single_uncommitted_changeset_matches_full_width_shape() { let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") @@ -9081,19 +9542,20 @@ mod tests { assert_eq!(app.files().len(), 1); assert_eq!( app.effective_zoom_for(0), - EffectiveZoom::Single(Role::Combined), - "empty staged/unstaged sub-models collapse every zoom to combined-only, for free" + EffectiveZoom::Single(Role::Whole), + "empty staged/unstaged sub-models collapse every zoom to whole-only, for free" ); // Read-only follows from the natural collapse above; the refusal MESSAGE is - // committed-mode-aware (m5-changeset-nav locked decision #2) — a plain "already + // committed-mode-aware (m5-changeset-nav's locked decision that committed mode is + // derived, not stored, with targeted guards) — a plain "already // committed" notice, not the uncommitted "cycle zoom" hint (there's no zoom that would // help here). app.stage_hunk(); let notice = app .notice .as_ref() - .expect("staging must refuse on a combined-only (committed) changeset"); + .expect("staging must refuse on a whole-only (committed) changeset"); assert!( notice.text.contains("already committed"), "got: {:?}", @@ -9189,10 +9651,11 @@ mod tests { assert_eq!(app.current_changeset().name, "current"); } - // ── M5 CS2: continuous nav, changeset nav, committed-mode guards ───────── + // ── Continuous changeset navigation, committed-mode guards ─────────────── - /// A two-committed-changeset stack for CS2's nav tests, hand-built the same way as the M5 CS1 - /// tests above: `cs-a` (`root..mid`, TWO files — `a1.txt`/`a2.txt`) then `cs-b` (`mid..head`, + /// A two-committed-changeset stack for the continuous-changeset-navigation work's nav + /// tests, hand-built the same way as the changeset-stack-spine tests above: `cs-a` + /// (`root..mid`, TWO files — `a1.txt`/`a2.txt`) then `cs-b` (`mid..head`, /// ONE file — `b1.txt`), opening on `cs-a`'s first file. The two-file first changeset lets a /// test distinguish "advance within a changeset" from "cross into the next changeset" at its /// boundary, rather than every `next_file` immediately crossing. @@ -9367,7 +9830,7 @@ mod tests { ); } - /// Regression: navigating to an OLDER committed changeset and loading its combined view must + /// Regression: navigating to an OLDER committed changeset and loading its whole view must /// source the new side from that changeset's `head` commit tree, not the current worktree. The /// same file `f.txt` is touched by both changesets, so `cs-a`'s head (`mid`) content differs /// from the worktree (which holds `head`'s content). Before the `new_side_tree_for` fix the new @@ -9392,7 +9855,7 @@ mod tests { .create("mid") .unwrap(); // cs-b (mid..head) adds "three" — so the checked-out worktree copy is "one\ntwo\nthree\n", - // three lines, which must NOT be what cs-a's combined new side reads. + // three lines, which must NOT be what cs-a's whole new side reads. let head = fixture .commit("main") .file("f.txt", "one\ntwo\nthree\n") @@ -9431,11 +9894,11 @@ mod tests { app.open_current(); assert_eq!(app.current_cs(), 1, "opens on cs-b (its current: true)"); - // Navigate back to the older changeset and load its combined view. Pre-fix this panics at + // Navigate back to the older changeset and load its whole view. Pre-fix this panics at // align.rs:165; post-fix it loads cleanly. app.prev_changeset(); assert_eq!(app.current_cs(), 0, "prev lands on cs-a"); - let view = app.current_view().expect("cs-a's combined view must load"); + let view = app.current_view().expect("cs-a's whole view must load"); assert_eq!( view.new_text(), @@ -9498,19 +9961,19 @@ mod tests { } #[test] - fn cycle_zoom_is_a_no_op_on_a_committed_changeset() { + fn toggle_maximize_is_a_no_op_on_a_committed_changeset() { let mut app = two_committed_changesets_two_and_one_files(); - let zoom_before = app.zoom; + let maximized_before = app.maximized; - app.cycle_zoom(); + app.toggle_maximize(); assert_eq!( - app.zoom, zoom_before, - "z must not change the requested zoom on a committed changeset" + app.maximized, maximized_before, + "Z must not change maximize on a committed changeset" ); assert!( app.notice.is_some(), - "z should still surface a notice explaining why it's a no-op" + "Z should still surface a notice explaining why it's a no-op" ); } @@ -9555,11 +10018,12 @@ mod tests { ); } - // ── M5 CS3: outline side pane ─────────────────────────────────────────────── + // ── The outline side pane (flat and stack modes) ────────────────────────────── /// A committed changeset (`base..head`, one file, not current) beneath an uncommitted /// changeset (one untracked file, current) — the mix the outline's "status column only for - /// the uncommitted changeset" test needs, hand-built the same way as every other M5 test in + /// the uncommitted changeset" test needs, hand-built the same way as every other + /// stack-and-outline test in /// this module (`Changeset` literal + `diff_changeset` + `ChangesetView::from_changeset_diff` /// for BOTH sources — the acquisition router handles either). fn committed_and_uncommitted_stack() -> App { @@ -9633,7 +10097,7 @@ mod tests { let lone = app_from_fixture(&fixture); assert!( !lone.outline_open(), - "a lone uncommitted changeset must keep the M4 full-width look (outline closed)" + "a lone uncommitted changeset must keep the original full-width look (outline closed)" ); } @@ -9791,7 +10255,8 @@ mod tests { #[test] fn outline_hscroll_right_has_no_upper_clamp_in_the_method_itself() { - // Locked decision #2: `outline_hscroll_right` floors at 0 but does NOT clamp against the + // The locked decision that outline pan floors at 0 and clamps render-side: + // `outline_hscroll_right` floors at 0 but does NOT clamp against the // outline's content width — that clamp is render-side (`render_outline`), covered in // `render.rs`'s tests. let mut app = two_committed_changesets_two_and_one_files(); @@ -9852,7 +10317,8 @@ mod tests { let owned = Repository::open(repo.workdir().unwrap()).unwrap(); let mut app = App::from_changesets(owned, vec![view_a, view_b]); app.outline.mode = OutlineMode::Stack; - // CS3: pin BaseFirst explicitly — this test asserts per-header marker content, not + // The outline side pane (flat and stack modes): pin BaseFirst explicitly — this test + // asserts per-header marker content, not // display order, so it doesn't need to track the new HeadFirst default. app.outline.order = OutlineOrder::BaseFirst; @@ -9966,7 +10432,8 @@ mod tests { let repo = Repository::open(fixture.repo().unwrap().workdir().unwrap()).unwrap(); let mut app = App::from_changesets(repo, vec![view_pending, view_failed]); app.outline.mode = OutlineMode::Stack; - // CS3: pin BaseFirst explicitly — this test asserts the exact header vec, which is + // The outline side pane (flat and stack modes): pin BaseFirst explicitly — this test + // asserts the exact header vec, which is // incidental to base -> head storage order here, not what's under test (the // loading/failed markers). app.outline.order = OutlineOrder::BaseFirst; @@ -10126,7 +10593,8 @@ mod tests { let owned = Repository::open(repo.workdir().unwrap()).unwrap(); let mut app = App::from_changesets(owned, vec![view_a, view_b]); app.outline.mode = OutlineMode::Stack; - // CS3: pin BaseFirst explicitly — the regression this test guards needs cs-a BEFORE + // The outline side pane (flat and stack modes): pin BaseFirst explicitly — the regression + // this test guards needs cs-a BEFORE // cs-b in the row list (an earlier row's insertion shifting a later row's index); the // new HeadFirst default would put cs-b (head) first instead, inverting the scenario. app.outline.order = OutlineOrder::BaseFirst; @@ -10204,7 +10672,8 @@ mod tests { ); } - /// CS5: `outline_snapshot`'s `change` field is lifted from the owning `FileChange::status`, + /// File-status letters and opt-in nerd icons: `outline_snapshot`'s `change` field is + /// lifted from the owning `FileChange::status`, /// a wholly separate axis from `status` (staged-ness — see `outline::OutlineFile::change`'s /// doc comment). `c1.txt` is a new file introduced by the committed changeset's head commit /// (`Added`); `u1.txt` is an untracked worktree file (`Untracked`) — distinct FileStatus @@ -10314,7 +10783,8 @@ mod tests { fn outline_move_by_on_a_file_row_jumps_the_diff() { let mut app = two_committed_changesets_two_and_one_files(); app.outline.mode = OutlineMode::Flat; - // CS3: pin BaseFirst explicitly — this test exercises `outline_move_by`'s row-crossing + // The outline side pane (flat and stack modes): pin BaseFirst explicitly — this test + // exercises `outline_move_by`'s row-crossing // mechanics via hardcoded Flat-mode indices, not display order. app.outline.order = OutlineOrder::BaseFirst; app.outline.cursor = 0; @@ -10336,7 +10806,8 @@ mod tests { fn outline_move_by_on_a_header_row_does_not_jump_the_diff() { let mut app = two_committed_changesets_two_and_one_files(); app.outline.mode = OutlineMode::Stack; - // CS3: pin BaseFirst explicitly — this test's hardcoded row indices assume base -> head + // The outline side pane (flat and stack modes): pin BaseFirst explicitly — this test's + // hardcoded row indices assume base -> head // order (header, a1, a2, header, b1); the new HeadFirst default is a display-order // concern orthogonal to what's under test here (whether a header move jumps the diff). app.outline.order = OutlineOrder::BaseFirst; @@ -10360,12 +10831,14 @@ mod tests { #[test] fn coalesced_outline_burst_onto_a_header_matches_sequential_unit_moves() { - // A multi-row delta is CS2's coalesced stand-in for N unit presses, so the two must be + // A multi-row delta is coalescing-buffered-navigation-input's coalesced stand-in for N unit + // presses, so the two must be // indistinguishable — including which file the diff follows when the burst stops on a // header row (the LAST file crossed, exactly where unit presses leave it). let mut coalesced = two_committed_changesets_two_and_one_files(); coalesced.outline.mode = OutlineMode::Stack; - // CS3: pin BaseFirst explicitly — the burst-vs-sequential equivalence under test doesn't + // The outline side pane (flat and stack modes): pin BaseFirst explicitly — the + // burst-vs-sequential equivalence under test doesn't // depend on which end of the stack displays first, and the inline comments below assume // base -> head row order. coalesced.outline.order = OutlineOrder::BaseFirst; @@ -10396,12 +10869,14 @@ mod tests { #[test] fn outline_confirm_on_a_header_row_toggles_fold_instead_of_jumping_and_keeps_focus() { - // CS5 (`outline-fold`) removes Enter's pre-CS5 jump-to-changeset-first-file behavior on a + // `outline-fold` removes Enter's pre-`outline-fold` jump-to-changeset-first-file behavior + // on a // Header row — it now toggles that row's fold instead, and deliberately does NOT return // focus (you're manipulating the outline, not confirming a jump). let mut app = two_committed_changesets_two_and_one_files(); app.outline.mode = OutlineMode::Stack; - // CS3: pin BaseFirst explicitly — cursor 3 is hardcoded to cs-b's header under base -> + // The outline side pane (flat and stack modes): pin BaseFirst explicitly — cursor 3 is + // hardcoded to cs-b's header under base -> // head row order; the toggle mechanic under test is order-agnostic. app.outline.order = OutlineOrder::BaseFirst; app.outline.open = true; @@ -10416,7 +10891,7 @@ mod tests { assert_eq!( app.current_cs(), before_cs, - "Enter on a header must NOT jump the diff (CS5)" + "Enter on a header must NOT jump the diff (outline-fold)" ); assert_eq!(app.current, before_file); assert!( @@ -10467,7 +10942,8 @@ mod tests { #[test] fn diff_initiated_nav_syncs_the_outline_cursor_in_tree_mode() { - // CS4: Tree mode's rows still carry the same cs_idx/file_idx a File row always has, so + // The outline's path-trie tree modes: Tree mode's rows still carry the same cs_idx/file_idx + // a File row always has, so // `sync_outline_to_current`'s match-by-those-fields logic needs no tree-specific branch — // this pins that it actually still lands correctly once the row also carries `guides`. let mut app = two_committed_changesets_two_and_one_files(); @@ -10580,7 +11056,7 @@ mod tests { ); assert!( app.outline_focused(), - "confirming a Dir row toggles its fold (CS5) rather than returning focus" + "confirming a Dir row toggles its fold (outline-fold) rather than returning focus" ); assert!( app.outline_items().len() < rows_before, @@ -10601,7 +11077,7 @@ mod tests { assert!(!app.outline_open()); } - // ── CS2: outline scrolloff viewport + g/G jumps ───────────────────────────── + // ── The outline scrolloff viewport + g/G jumps ─────────────────────────────── /// Four committed changesets of three files each — Stack mode (the default) yields 16 rows /// (header + 3 files, ×4), long enough to exercise [`App::derive_outline_scroll`]'s margin @@ -10718,7 +11194,8 @@ mod tests { #[test] fn outline_top_lands_cursor_zero_and_does_not_jump_a_header() { - // CS3: the outline's default order is now HeadFirst, so Stack mode's row 0 is cs-b's + // The outline side pane (flat and stack modes): the default order is now HeadFirst, so + // Stack mode's row 0 is cs-b's // (the head changeset's) header, not cs-a's — see // `stack_mode_head_first_shows_last_changesets_header_first_with_true_cs_idx` in // outline.rs for the row-order pin. `outline_top`'s own contract (row 0, no diff jump) @@ -10746,7 +11223,8 @@ mod tests { #[test] fn outline_bottom_lands_on_the_last_row_and_jumps_a_file() { - // CS3: under the new HeadFirst default, Stack mode's row order is cs-b's header/file(s) + // The outline side pane (flat and stack modes): under the new HeadFirst default, Stack + // mode's row order is cs-b's header/file(s) // first, then cs-a's — so the LAST row is cs-a's last file (a2.txt, cs_idx 0, file_idx // 1), not cs-b's only file as it was under the old base-first order. let mut app = two_committed_changesets_two_and_one_files(); @@ -10799,7 +11277,7 @@ mod tests { ); } - // ── CS7: view-config (`apply_view_config`) ───────────────────────────────── + // ── The view-config settings (`apply_view_config`) ─────────────────────────── #[test] fn unset_view_config_keeps_current_defaults() { @@ -10815,7 +11293,6 @@ mod tests { assert_eq!(app.outline_order(), OutlineOrder::default()); assert_eq!(app.icon_mode(), IconMode::default()); assert_eq!(app.layout, Layout::default()); - assert_eq!(app.zoom, Zoom::default()); assert_eq!(app.diff_text, DiffTextMode::default()); } @@ -10847,7 +11324,8 @@ mod tests { assert_eq!(app.outline_width(), DEFAULT_OUTLINE_WIDTH); assert_eq!(warnings.len(), 1); - // Full-message pin (config-validation-completeness Decision 5): the range and fallback + // Full-message pin (invalid-value warnings name the allowed set and the fallback): the + // range and fallback // must come from the real `MIN_OUTLINE_WIDTH`/`MAX_OUTLINE_WIDTH`/`DEFAULT_OUTLINE_WIDTH` // constants, never hardcoded numbers. assert_eq!( @@ -10989,37 +11467,6 @@ mod tests { assert!(warnings[0].contains("diff.layout")); } - #[test] - fn diff_zoom_overrides_default_when_set() { - let fixture = FixtureBuilder::new() - .config("workon.review.diff.zoom", "staged") - .build() - .unwrap(); - let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); - let mut app = app_from_fixture(&fixture); - - let warnings = app.apply_view_config(&config); - - assert!(warnings.is_empty()); - assert_eq!(app.zoom, Zoom::Staged); - } - - #[test] - fn diff_zoom_invalid_falls_back_to_default_with_warning() { - let fixture = FixtureBuilder::new() - .config("workon.review.diff.zoom", "bogus") - .build() - .unwrap(); - let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); - let mut app = app_from_fixture(&fixture); - - let warnings = app.apply_view_config(&config); - - assert_eq!(app.zoom, Zoom::default()); - assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("diff.zoom")); - } - #[test] fn diff_text_overrides_default_when_set() { let fixture = FixtureBuilder::new() @@ -11137,13 +11584,14 @@ mod tests { ); } - // ── CS4: summary panel ─────────────────────────────────────────────────────── + // ── The summary panel ───────────────────────────────────────────────────────── /// Force the outline open+focused with `mode` and `cursor`, matching the state /// `summary_target` requires — the individual state-transition tests below build off this /// instead of repeating the three-field setup. Pins `order` to `BaseFirst` so a fixture's /// base -> head file/changeset indices line up with display order (the default `HeadFirst` - /// reverses the header row sequence — irrelevant to what's under test here, see CS3). + /// reverses the header row sequence — irrelevant to what's under test here, see the + /// outline side pane's stack-and-outline work). fn open_focused_outline(app: &mut App, mode: OutlineMode, cursor: usize) { app.outline.open = true; app.outline.focused = true; @@ -11284,11 +11732,12 @@ mod tests { assert_eq!(paths, vec!["src/a.txt", "src/b.txt"]); } - // ── CS7: stage/unstage/discard from outline rows ───────────────────────────── + // ── The outline staging verbs: stage/unstage/discard from outline rows ───────── - /// Find the [`OutlineItem::File`] row index whose full path is `path` (in the CURRENT outline - /// mode/order) — the CS7 tests' stand-in for "click the row named X", since a row's raw index - /// shifts with mode/order and none of these tests want to hardcode it. + /// Find the [`OutlineItem::File`] row index whose full path is `path` (in the CURRENT + /// outline mode/order) — the outline-staging-verbs tests' stand-in for "click the row named + /// X", since a row's raw index shifts with mode/order and none of these tests want to + /// hardcode it. fn outline_file_row(app: &App, path: &str) -> usize { app.outline_items() .iter() @@ -11737,7 +12186,7 @@ mod tests { } } - // ── CS5 (`outline-fold`): collapse/expand ─────────────────────────────────── + // ── `outline-fold`: collapse/expand ───────────────────────────────────────── #[test] fn outline_toggle_fold_hides_the_headers_files_and_move_by_skips_them() { @@ -11848,7 +12297,7 @@ mod tests { app.outline_confirm(); // collapse cs-b's header assert!( app.outline_focused(), - "toggling a fold keeps focus (CS5) — sanity for the nav below" + "toggling a fold keeps focus (outline-fold) — sanity for the nav below" ); // A diff-initiated nav lands the diff on cs-b's (now-hidden) first file. @@ -12071,7 +12520,8 @@ mod tests { #[test] fn outline_stage_targets_the_correct_row_when_an_unrelated_header_is_folded() { - // The highest-risk CS5 interaction: folding one changeset's header shifts every LATER + // The highest-risk outline-fold interaction: folding one changeset's header shifts every + // LATER // row's index in `outline_items()` — a stage/discard verb resolved against a stale // (unfiltered) index space would silently act on the wrong file. `outline_stage` reads // `outline_row_targets`, which reads `outline_items()` at the CURSOR's own index — the @@ -12129,7 +12579,7 @@ mod tests { repo.assert(predicate::repo::has_staged_file("dirty.txt")); } - // ── CS8: progressive gap expansion ────────────────────────────────────── + // ── Progressive gap expansion ───────────────────────────────────────────── /// A single-file fixture with two hunks separated by a wide (40-line) unchanged run — wide /// enough that even a full 10/10 [`App::expand_gap_at_cursor`] press still leaves a @@ -12152,7 +12602,8 @@ mod tests { .unwrap() } - /// The display-row index of the current file's ONLY gap row — the fixture shape every CS8 + /// The display-row index of the current file's ONLY gap row — the fixture shape every + /// progressive-gap-expansion /// expansion test below relies on. fn only_gap_row(app: &App) -> usize { app.current_view_ref() @@ -12264,7 +12715,8 @@ mod tests { app.expand_gap_at_cursor(false); // Move to hunk B (the LATER hunk) through the freshly rebuilt `display`/`display_hunk` — - // this is the coordinate-space desync CS8 must not introduce: `display_hunk` is + // this is the coordinate-space desync progressive gap expansion must not introduce: + // `display_hunk` is // recomputed by `rebuild_rows` from the SAME `aligned`/`hunks` every time, so the row // under the cursor must still resolve to the right hunk index after an expansion. app.next_hunk_row(); @@ -12350,7 +12802,7 @@ mod tests { ); } - // ── diff-fold-keys CS3: reset (`zM`) / expand-all (`zR`) gaps ─────────── + // ── `diff-fold-keys`: reset (`zM`) / expand-all (`zR`) gaps ────────────── #[test] fn reset_gaps_collapses_an_expanded_gap_back_to_the_freshly_loaded_shape() { @@ -12495,10 +12947,11 @@ mod tests { ); } - // ── M11 CS3 (`diff-search`) ────────────────────────────────────────────── + // ── The in-diff search (`diff-search`) ───────────────────────────────────── /// [`two_hunks_with_a_wide_gap_fixture`], but the middle of the hidden context run carries a - /// unique needle (`ctx20` → `needle_line`) — CS3's "hidden-context rows are searchable, and + /// unique needle (`ctx20` → `needle_line`) — the in-diff search's "hidden-context rows are + /// searchable, and /// jumping to one auto-expands its gap" fixture. fn two_hunks_with_a_buried_needle_fixture() -> Fixture { let mut committed = String::from("OLD_HUNK_A\n"); @@ -12542,18 +12995,20 @@ mod tests { } #[test] - fn search_accept_jumps_to_the_first_match_and_auto_expands_its_gap() { + fn search_accept_jumps_to_the_first_match_and_reveals_its_gap_around_it() { let fixture = two_hunks_with_a_buried_needle_fixture(); let mut app = app_from_fixture(&fixture); app.open_current(); - assert!( - app.current_view_ref() - .unwrap() - .display - .iter() - .any(|r| matches!(r, DisplayRow::Gap { .. })), - "precondition: the fixture's wide context run must start out collapsed" - ); + let skipped_before = app + .current_view_ref() + .unwrap() + .display + .iter() + .find_map(|r| match r { + DisplayRow::Gap { skipped, .. } => Some(*skipped), + _ => None, + }) + .expect("precondition: the fixture's wide context run must start out collapsed"); app.search_focus(); for c in "needle".chars() { @@ -12562,24 +13017,111 @@ mod tests { app.search_accept(); let view = app.current_view_ref().unwrap(); + let skipped_after = view.display.iter().find_map(|r| match r { + DisplayRow::Gap { skipped, .. } => Some(*skipped), + _ => None, + }); assert!( - !view - .display - .iter() - .any(|r| matches!(r, DisplayRow::Gap { .. })), - "jumping to a match buried in the gap must fully reveal it: {:?}", + skipped_after.is_some_and(|skipped| skipped < skipped_before), + "the reveal is BOUNDED, not full: some of the run must still be collapsed, just \ + less of it than before (was {skipped_before} skipped, now {skipped_after:?}): {:?}", view.display ); match view.display[app.cursor] { DisplayRow::Row(row) => { assert_eq!(row.old, Row::Line(21), "needle_line is old-side line 21"); } - other => panic!("expected the cursor to land on the needle's row, got {other:?}"), + other => panic!( + "expected the cursor to land on the needle's row (revealed by the bounded \ + expansion), got {other:?}" + ), } assert!(!app.search_focused(), "accept must close the prompt"); assert!(app.search_active()); } + /// One wide hidden context run (50 lines) with two needles far apart inside it — `needleA` + /// near the leading edge (line 10), `needleB` near the trailing edge (line 35) — so jumping to + /// each in turn widens the SAME gap from opposite edges. The in-diff search's "repeated jumps + /// into one gap + /// accumulate rather than reset" fixture. + fn one_gap_with_two_needles_fixture() -> Fixture { + let mut committed = String::from("OLD_HUNK_A\n"); + let mut modified = String::from("NEW_HUNK_A\n"); + for i in 1..=50 { + let line = match i { + 10 => "needleA".to_string(), + 35 => "needleB".to_string(), + _ => format!("ctx{i}"), + }; + committed.push_str(&line); + committed.push('\n'); + modified.push_str(&line); + modified.push('\n'); + } + committed.push_str("OLD_HUNK_B\n"); + modified.push_str("NEW_HUNK_B\n"); + + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", &committed, &modified) + .build() + .unwrap() + } + + #[test] + fn jump_to_search_match_accumulates_expansion_across_repeated_jumps_into_one_gap() { + let fixture = one_gap_with_two_needles_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + app.search_focus(); + for c in "needle".chars() { + app.search_insert_char(c); + } + app.search_accept(); + assert_eq!(app.search_matches().len(), 2, "both needles must be found"); + + let key = *app + .current_view_ref() + .unwrap() + .expansions + .keys() + .next() + .expect("jumping to needleA must have created a gap expansion entry"); + let after_first = app.current_view_ref().unwrap().expansions[&key]; + assert!( + !after_first.full, + "a bounded reveal must not flip the gap's `full` flag" + ); + assert!( + after_first.before > 0, + "needleA sits nearer the gap's leading edge, so the first jump must widen `before`" + ); + assert_eq!( + after_first.after, 0, + "the first jump must not have touched the trailing edge yet" + ); + + // needleB is still buried under the (now-narrower) gap — jumping to it must widen the + // TRAILING edge on top of the leading-edge widening the first jump already did, not + // discard it. + app.search_next(); + let after_second = app.current_view_ref().unwrap().expansions[&key]; + assert_eq!( + after_second.before, after_first.before, + "expand_gap accumulates: the second jump must not reset the first jump's `before` widening" + ); + assert!( + after_second.after > 0, + "needleB sits nearer the gap's trailing edge, so the second jump must widen `after`" + ); + assert!( + !after_second.full, + "two bounded reveals into a 50-row run must not have consumed the whole gap" + ); + } + #[test] fn search_next_and_prev_wrap_with_a_footer_notice() { // Two occurrences of the SAME needle on two different visible lines (both hunk change @@ -12677,7 +13219,113 @@ mod tests { assert!(app.search_matches().is_empty()); } - // ── CS9: reveal gaps to the enclosing tree-sitter scope ───────────────── + #[test] + fn toggle_layout_preserves_the_current_search_match() { + // Two matches so landing on the SECOND one (rather than the first, which a fresh + // recompute would also happen to pick) proves the index is actually carried across, + // not coincidentally re-derived. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "f.txt", + "alpha old\nctx\nbeta old\n", + "alpha needle\nctx\nbeta needle\n", + ) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + app.search_focus(); + for c in "needle".chars() { + app.search_insert_char(c); + } + app.search_accept(); + app.search_next(); + assert_eq!( + app.search_current_index(), + Some(1), + "precondition: parked on the second match" + ); + + app.toggle_layout(); + assert_eq!( + app.search_current_index(), + Some(1), + "a same-file layout flip must not lose the 'parked on match N' highlight — matches \ + address the layout-agnostic AlignedRow space, so it's still valid" + ); + assert_eq!( + app.search_matches().len(), + 2, + "the match list itself must still be intact after the flip" + ); + + // Flip back: still preserved, not a one-shot fluke of the first toggle. + app.toggle_layout(); + assert_eq!(app.search_current_index(), Some(1)); + } + + #[test] + fn search_current_still_resets_on_a_query_change_and_a_file_switch() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "a.txt", + "alpha old\nctx\nbeta old\n", + "alpha needle\nctx\nbeta needle\n", + ) + .unstaged_file("b.txt", "old\n", "needle\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + app.search_focus(); + for c in "needle".chars() { + app.search_insert_char(c); + } + app.search_accept(); + app.search_next(); + assert_eq!(app.search_current_index(), Some(1), "precondition"); + + // A file switch funnels through `reset_panes`, not the layout-toggle path — genuinely a + // different file's match list, so the old index has no claim to carry over. + app.next_file(); + assert_eq!( + app.search_current_index(), + None, + "switching files must still drop the parked-match highlight" + ); + + // Back on the first file: re-accepting is a query-change-shaped recompute (the plan's + // "changed query" case), which must also reset even though the match list ends up + // identical to before. + app.prev_file(); + app.search_focus(); + for c in "needle".chars() { + app.search_insert_char(c); + } + app.search_accept(); + app.search_next(); + assert_eq!( + app.search_current_index(), + Some(1), + "re-primed precondition" + ); + + app.search_backspace(); + app.search_insert_char('e'); // buffer back to "needle" — same effective query + assert_eq!( + app.search_current_index(), + None, + "a live prompt edit must reset the parked-match highlight even if the resulting \ + query is unchanged — the in-diff search only carries the index across a layout \ + flip, nothing else" + ); + } + + // ── The tree-sitter scope reveal ────────────────────────────────────────── /// A `.rs` fixture where both edits sit inside the SAME long function, with a 40-line /// unchanged run between them wide enough that even a +10/+10 press would still leave a @@ -12724,7 +13372,8 @@ mod tests { /// The `skipped` count of the current file's only [`DisplayRow::Gap`], found by scanning /// `display` (NOT via `app.cursor` — expanding the gap's leading edge shifts the gap marker - /// to a later index, same as [`only_gap_row`] re-finds it after an expansion in the CS8 + /// to a later index, same as [`only_gap_row`] re-finds it after an expansion in the + /// progressive-gap-expansion /// tests above). Panics if there isn't exactly one gap row. fn gap_skipped(app: &App) -> usize { let row = only_gap_row(app); @@ -12760,8 +13409,9 @@ mod tests { #[test] fn a_grammarless_file_falls_back_to_the_flat_plus_ten_reveal() { - // Reuse CS8's `.txt` fixture (no bundled grammar for that extension) — the scope-reveal - // path must find no lang key and fall straight through to +10/+10, same as before CS9. + // Reuse progressive gap expansion's `.txt` fixture (no bundled grammar for that + // extension) — the scope-reveal path must find no lang key and fall straight through + // to +10/+10, same as before the tree-sitter scope reveal. let fixture = two_hunks_with_a_wide_gap_fixture(); let mut app = app_from_fixture(&fixture); app.open_current(); @@ -12776,7 +13426,8 @@ mod tests { assert_eq!( skipped_before - skipped_after, 20, - "no grammar for .txt: exactly the flat 10-before/10-after reveal, same as CS8" + "no grammar for .txt: exactly the flat 10-before/10-after reveal, same as \ + progressive gap expansion" ); } @@ -12805,8 +13456,9 @@ mod tests { #[test] fn full_expand_ignores_scope_reveal_regardless_of_grammar() { - // `E` (full=true) must stay pure CS8 behavior even on a file with a grammar and a scope - // that would otherwise apply — scope-reveal is an `Enter`-only (CS9) refinement. + // `E` (full=true) must stay pure progressive-gap-expansion behavior even on a file + // with a grammar and a scope that would otherwise apply — scope-reveal is an + // `Enter`-only (tree-sitter scope reveal) refinement. let fixture = function_with_a_wide_internal_gap_fixture(); let mut app = app_from_fixture(&fixture); app.open_current(); @@ -12827,7 +13479,7 @@ mod tests { ); } - // ── CS10: mouse (click-to-focus, wheel scrolling) ──────────────────────────── + // ── Mouse support (click-to-focus, wheel scrolling) ──────────────────────────── #[test] fn click_on_an_outline_file_row_focuses_selects_and_jumps_the_diff() { @@ -13161,7 +13813,8 @@ mod tests { let hscroll_before = app.hscroll; let outline_hscroll_before = app.outline_hscroll(); - // On the divider, outside both recorded regions — same column CS10's click no-op test + // On the divider, outside both recorded regions — same column mouse support's click no-op + // test // uses. app.handle_hwheel(20, 0, 4); @@ -13206,7 +13859,7 @@ mod tests { assert_eq!((app.current_cs(), app.current), current_before); } - // ── CS2 (`outline-filter`, M11): fuzzy filter ───────────────────────────────── + // ── The outline fuzzy filter (`outline-filter`) ─────────────────────────────── #[test] fn outline_items_applies_the_active_filter_and_keeps_true_indices() { @@ -13396,7 +14049,316 @@ mod tests { app.outline_cursor(), cursor_before.min(app.outline_items().len().saturating_sub(1)), "the cursor merely clamps into the filtered list's bounds, exactly like the \ - pre-CS2 fallback for an unresolvable sync target" + pre-outline-fuzzy-filter fallback for an unresolvable sync target" + ); + } + + // ── `copy-lines` / `copy-location` (`yank split`) ──────────────────────── + + /// A single pure deletion — `b` (old line 2) removed with nothing added in its place — so + /// the row it produces has an old lineno but NO new one, the fallback case + /// [`resolve_yank_rows`]'s doc names. + fn pure_deletion_fixture() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "a\nb\nc\n", "a\nc\n") + .build() + .unwrap() + } + + /// A single pure addition — `b` (new line 2) inserted with nothing removed — the mirror of + /// [`pure_deletion_fixture`]: this row has a new lineno but no old one, the ordinary case + /// (new-side wins, no fallback needed). + fn pure_addition_fixture() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "a\nc\n", "a\nb\nc\n") + .build() + .unwrap() + } + + // These target `App::resolve_copy_location`/`App::resolve_copy_lines` directly rather than + // `App::copy_location`/`App::copy_lines` — resolution is pure, but the verbs themselves write + // to `/dev/tty` via `crate::clipboard::write_osc52`, which is `ENXIO` in a test harness/CI + // with no controlling tty. Asserting through the notice text would make line resolution + // depend on a real terminal for no reason; the byte-sequence tests in `clipboard.rs` cover + // the write side. + + #[test] + fn copy_location_uses_the_new_side_on_a_context_row_in_both_layouts() { + let fixture = pure_addition_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.cursor = 0; // the leading "a" context row: old 1, new 1 + + assert_eq!(app.resolve_copy_location(), Ok("f.txt:1".to_string())); + + app.toggle_layout(); + app.cursor = 0; + assert_eq!(app.resolve_copy_location(), Ok("f.txt:1".to_string())); + } + + #[test] + fn copy_location_uses_the_new_side_on_a_pure_addition_row_in_both_layouts() { + let fixture = pure_addition_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let row = app + .current_view_ref() + .unwrap() + .display + .iter() + .position(|r| matches!(r, DisplayRow::Row(row) if row.new == Row::Line(2))) + .expect("the inserted 'b' has its own SBS row at new line 2"); + app.cursor = row; + + assert_eq!(app.resolve_copy_location(), Ok("f.txt:2".to_string())); + + app.toggle_layout(); + let inline_row = app + .current_view_ref() + .unwrap() + .inline + .iter() + .position(|r| matches!(r, InlineRow::Add { new: 2, .. })) + .expect("the inserted 'b' has its own inline Add row"); + app.cursor = inline_row; + assert_eq!(app.resolve_copy_location(), Ok("f.txt:2".to_string())); + } + + #[test] + fn copy_location_falls_back_to_the_old_lineno_on_a_pure_deletion_row_in_both_layouts() { + let fixture = pure_deletion_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let row = app + .current_view_ref() + .unwrap() + .display + .iter() + .position(|r| matches!(r, DisplayRow::Row(row) if row.old == Row::Line(2))) + .expect("the deleted 'b' has its own SBS row at old line 2"); + app.cursor = row; + + assert_eq!( + app.resolve_copy_location(), + Ok("f.txt:2".to_string()), + "no new side on a pure deletion: falls back to the old lineno" ); + + app.toggle_layout(); + let inline_row = app + .current_view_ref() + .unwrap() + .inline + .iter() + .position(|r| matches!(r, InlineRow::Del { old: 2, .. })) + .expect("the deleted 'b' has its own inline Del row"); + app.cursor = inline_row; + assert_eq!(app.resolve_copy_location(), Ok("f.txt:2".to_string())); + } + + #[test] + fn copy_location_resolver_errs_instead_of_returning_garbage_on_a_gap_row_in_both_layouts() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.cursor = only_gap_row(&app); + + assert_eq!( + app.resolve_copy_location(), + Err("no line to copy"), + "a gap row carries neither an old nor a new lineno" + ); + + app.toggle_layout(); + let inline_gap = app + .current_view_ref() + .unwrap() + .inline + .iter() + .position(|r| matches!(r, InlineRow::Gap { .. })) + .expect("the same wide context run collapses to an inline Gap row too"); + app.cursor = inline_gap; + assert_eq!(app.resolve_copy_location(), Err("no line to copy")); + } + + /// Multi-row selection -> content, in both layouts, over a range spanning a deletion, an + /// addition, and a context row (`two_changes_one_hunk_fixture`: `a b c d e` -> `a B c D e`). + /// SBS pairs `b`/`B` and `d`/`D` into single rows each carrying both sides, so the range + /// `[paired(b,B), context c, paired(d,D)]` resolves to the NEW side throughout (which side a + /// row contributes): + /// `B`, `c`, `D`. + #[test] + fn multi_row_selection_copies_content_spanning_del_add_context_sbs() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert_eq!(app.current_view_ref().unwrap().display.len(), 5); + app.cursor = 1; + app.selection_anchor = Some(1); + app.cursor = 3; + + assert_eq!(app.resolve_copy_lines(), Ok("B\nc\nD".to_string())); + } + + /// Inline analog: the same span becomes `Del(b) Add(B) Context(c) Del(d) Add(D)` — selecting + /// from the first `Add` through the second `Add` picks up `Add(B) Context(c) Del(d) Add(D)`. + /// Unlike SBS, inline is per-side precise (which side a row contributes): the `Del(d)` row in + /// the middle + /// contributes its OLD text (`d`), separately from the following `Add(D)`'s NEW text — this + /// is the row-precision inline exists for, not a bug. + #[test] + fn multi_row_selection_copies_content_spanning_del_add_context_inline() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.toggle_layout(); + let inline = &app.current_view_ref().unwrap().inline; + let lo = inline + .iter() + .position(|r| matches!(r, InlineRow::Add { new: 2, .. })) + .expect("the b->B add has its own inline row"); + let hi = inline + .iter() + .position(|r| matches!(r, InlineRow::Add { new: 4, .. })) + .expect("the d->D add has its own inline row"); + app.cursor = lo; + app.selection_anchor = Some(lo); + app.cursor = hi; + + assert_eq!(app.resolve_copy_lines(), Ok("B\nc\nd\nD".to_string())); + } + + /// Multi-row selection -> `path:lo-hi`, both layouts, over the same del/add/context span. + #[test] + fn multi_row_selection_copies_a_lo_hi_location_range_both_layouts() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.cursor = 1; + app.selection_anchor = Some(1); + app.cursor = 3; + + assert_eq!(app.resolve_copy_location(), Ok("f.txt:2-4".to_string())); + + app.cancel_selection(); + app.toggle_layout(); + let inline = &app.current_view_ref().unwrap().inline; + let lo = inline + .iter() + .position(|r| matches!(r, InlineRow::Add { new: 2, .. })) + .expect("the b->B add has its own inline row"); + let hi = inline + .iter() + .position(|r| matches!(r, InlineRow::Add { new: 4, .. })) + .expect("the d->D add has its own inline row"); + app.cursor = lo; + app.selection_anchor = Some(lo); + app.cursor = hi; + + assert_eq!(app.resolve_copy_location(), Ok("f.txt:2-4".to_string())); + } + + /// Single-row selection collapses to the single-line `path:12` form (the `path:lo-hi` range + /// location format), not + /// `path:12-12`. + #[test] + fn single_row_selection_collapses_to_the_single_line_location_form() { + let fixture = two_changes_one_hunk_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.cursor = 1; // the paired b->B row + app.selection_anchor = Some(1); + + assert_eq!(app.resolve_copy_location(), Ok("f.txt:2".to_string())); + } + + /// A selection spanning a gap row: the gap contributes nothing (gap rows inside a range are + /// skipped), but its + /// neighbors on either side are still copied. + #[test] + fn selection_spanning_a_gap_row_skips_the_gap_but_copies_its_neighbors() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let gap_row = only_gap_row(&app); + assert!(gap_row > 0, "expects at least one row before the gap"); + app.cursor = gap_row - 1; + app.selection_anchor = Some(gap_row - 1); + app.cursor = gap_row + 1; + + let content = app + .resolve_copy_lines() + .expect("neighbors on either side of the gap still resolve"); + assert_eq!( + content.lines().count(), + 2, + "exactly the two non-gap neighbors, the gap row itself contributes nothing: {content:?}" + ); + } + + /// A range resolving to no text at all (every row a gap) errs rather than writing an empty + /// clipboard payload. + #[test] + fn an_all_gap_range_errs_instead_of_copying_empty() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let gap_row = only_gap_row(&app); + app.cursor = gap_row; + app.selection_anchor = Some(gap_row); + + assert_eq!(app.resolve_copy_lines(), Err("no line to copy")); + assert_eq!(app.resolve_copy_location(), Err("no line to copy")); + } + + /// Content yank in `Role::Whole` succeeds — the locked decision that there is no + /// whole-role exemption for yank pins this against a future "helpful" refusal: the + /// side-selection rule (which side a row contributes) is total (it always yields a side), so + /// unlike the staging verbs there is nothing to refuse. `start_selection` itself still gates + /// whole role (it's a staging-shaped verb), so the selection is set directly here rather than + /// through `v`. ADR-038: `Role::Whole` for a file with real content is now only reachable + /// on a committed changeset (a binary file has no loaded view to copy from), so this exercises + /// it there instead of via a forced `Zoom::Combined`. + #[test] + fn content_yank_succeeds_in_whole_role() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("f.txt", "a\nb\nc\nd\ne\n") + .create("base") + .unwrap(); + let head = fixture + .commit("main") + .file("f.txt", "a\nB\nc\nD\ne\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + let cs = Changeset { + name: "main".to_string(), + span: ChangesetSpan::Committed { base, head }, + title: None, + current: true, + needs_restack: false, + }; + let diff = crate::acquire::diff_changeset(repo, &cs).unwrap(); + let view = ChangesetView::from_changeset_diff(cs, diff); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view]); + app.open_current(); + assert_eq!( + app.staging_role(), + None, + "a committed changeset always resolves to Role::Whole (effective_zoom)" + ); + app.cursor = 1; + app.selection_anchor = Some(1); + app.cursor = 3; + + assert_eq!(app.resolve_copy_lines(), Ok("B\nc\nD".to_string())); } } diff --git a/git-workon-review/src/apply.rs b/git-workon-review/src/apply.rs index 49593853..1cab583a 100644 --- a/git-workon-review/src/apply.rs +++ b/git-workon-review/src/apply.rs @@ -1,9 +1,9 @@ //! Applying a [`PatchText`] to a repository's index or working tree — the one chokepoint -//! (per the M2 design decision) parameterizable over two backends: [`Git2Applier`] (libgit2's -//! `Repository::apply`) and [`CliApplier`] (`git apply` on stdin). The round-trip corpus (CS6) -//! runs every scenario against both; `CliApplier` is the oracle. +//! (per the diff-model-and-patch-synthesis design decision) parameterizable over two backends: +//! [`Git2Applier`] (libgit2's `Repository::apply`) and [`CliApplier`] (`git apply` on stdin). +//! The round-trip verdict corpus runs every scenario against both; `CliApplier` is the oracle. //! -//! ## The flag matrix (trap 1's chokepoint, prototype-verified) +//! ## The flag matrix (the direction-dependent-drop-rules chokepoint, prototype-verified) //! //! `git apply` takes ONLY `--cached`/`--reverse`, patch on stdin — never `--unidiff-zero`, //! never `--3way`. [`StageVerb::plan`] encodes the same matrix for both backends: @@ -44,8 +44,8 @@ pub enum ApplyDirection { } /// The three staging actions a review session performs. [`StageVerb::plan`] is the flag -/// matrix above, encoded once so `ops.rs` (CS4) and the applier tests share one source of -/// truth. +/// matrix above, encoded once so `ops.rs` (the whole-file ops and routing layer) and the +/// applier tests share one source of truth. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StageVerb { Stage, @@ -124,7 +124,7 @@ impl Applier for Git2Applier { /// Applies by spawning `git apply` with the patch on stdin, cwd set to the repository's /// working directory. `Index` destination -> `--cached`; `Reverse` direction -> `--reverse`. -/// Never `--unidiff-zero`, never `--3way` (prototype chokepoint, trap 1). +/// Never `--unidiff-zero`, never `--3way` (prototype chokepoint, direction-dependent drop rules). pub struct CliApplier; impl Applier for CliApplier { diff --git a/git-workon-review/src/attribute.rs b/git-workon-review/src/attribute.rs deleted file mode 100644 index 9e76c020..00000000 --- a/git-workon-review/src/attribute.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! Staged-ness attribution for the **combined** (`HEAD` ↔ worktree) view — locked decision #7 in -//! the M4 plan. Pure: given a file's unstaged/staged sub-[`FileChange`]s (already looked up by -//! `App` via its `unstaged_idx`/`staged_idx` mapping), produces two membership sets keyed by the -//! exact line numbers the combined view's `AlignedRow`s already carry. No content matching, no -//! reconstruction — our rows carry real `old_lnum`/`new_lnum`, unlike the -//! `review-tui-spike` prototype's renderer, which had to reconstruct them and so needed a -//! del-run anchor heuristic to stay in sync with its picker. That heuristic has no analog here. -//! -//! ## The asymmetry (forced by coordinate alignment, not a stylistic choice) -//! -//! The combined view's OLD side is `HEAD` — the same "old" reference as the **staged** diff -//! (`HEAD` ↔ index). So a combined **deletion** at `old_lnum` M is "already staged" exactly when -//! the staged diff also deletes `old_lnum` M. -//! -//! The combined view's NEW side is the worktree — the same "new" reference as the **unstaged** -//! diff (index ↔ worktree). So a combined **addition** at `new_lnum` N is "not yet staged" -//! exactly when the unstaged diff also adds `new_lnum` N. -//! -//! These are two different sub-diffs keyed on two different sides — do not "fix" this to be -//! symmetric; the asymmetry is what makes the lookup correct. - -use std::collections::HashSet; - -use crate::model::{FileChange, LineKind}; - -/// Per-file membership sets built fresh each frame (see `render`'s combined-role render path) — -/// never cached on `App`, since the index can move out from under a stale cache (the M4 index -/// watcher refreshes the index independently of the render loop). -#[derive(Debug, Clone, Default)] -pub struct Attribution { - /// `new_lnum`s of every addition in the file's UNSTAGED (index ↔ worktree) sub-diff. An - /// combined Add cell at one of these lines is NOT YET staged (renders bright). - pub unstaged_adds: HashSet, - /// `old_lnum`s of every deletion in the file's STAGED (`HEAD` ↔ index) sub-diff. A combined - /// Del cell at one of these lines IS already staged (renders dim). - pub staged_dels: HashSet, -} - -impl Attribution { - /// Build from the current file's unstaged/staged sub-`FileChange`s, either of which may be - /// absent (the file has no change in that role) — an absent role contributes an empty set on - /// its axis rather than an error, so a file with no staged sub-diff renders every Del bright, - /// and a file with no unstaged sub-diff renders every Add dim. - pub fn build(unstaged: Option<&FileChange>, staged: Option<&FileChange>) -> Self { - let mut unstaged_adds = HashSet::new(); - if let Some(file) = unstaged { - for hunk in &file.hunks { - for line in &hunk.lines { - if line.kind == LineKind::Addition { - if let Some(n) = line.new_lnum { - unstaged_adds.insert(n); - } - } - } - } - } - - let mut staged_dels = HashSet::new(); - if let Some(file) = staged { - for hunk in &file.hunks { - for line in &hunk.lines { - if line.kind == LineKind::Deletion { - if let Some(n) = line.old_lnum { - staged_dels.insert(n); - } - } - } - } - } - - Self { - unstaged_adds, - staged_dels, - } - } - - /// True when a combined Add cell at `new_lnum` is NOT YET staged (renders bright). - pub fn add_is_unstaged(&self, new_lnum: u32) -> bool { - self.unstaged_adds.contains(&new_lnum) - } - - /// True when a combined Del cell at `old_lnum` IS already staged (renders dim). - pub fn del_is_staged(&self, old_lnum: u32) -> bool { - self.staged_dels.contains(&old_lnum) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::{Hunk, HunkLine}; - - fn line(kind: LineKind, old: Option, new: Option) -> HunkLine { - HunkLine { - kind, - content: Vec::new(), - old_lnum: old, - new_lnum: new, - missing_newline: false, - } - } - - fn file_with_hunks(hunks: Vec) -> FileChange { - FileChange { - path: "f.txt".to_string(), - old_path: None, - status: crate::model::FileStatus::Modified, - is_binary: false, - old_mode: 0o100644, - new_mode: 0o100644, - hunks, - } - } - - fn hunk(lines: Vec) -> Hunk { - Hunk { - old_start: 1, - old_count: 1, - new_start: 1, - new_count: 1, - header: Vec::new(), - lines, - } - } - - #[test] - fn absent_staged_sub_diff_yields_empty_staged_dels() { - let unstaged = file_with_hunks(vec![hunk(vec![line(LineKind::Addition, None, Some(3))])]); - let attribution = Attribution::build(Some(&unstaged), None); - assert!(attribution.staged_dels.is_empty()); - assert!(attribution.unstaged_adds.contains(&3)); - } - - #[test] - fn absent_unstaged_sub_diff_yields_empty_unstaged_adds() { - let staged = file_with_hunks(vec![hunk(vec![line(LineKind::Deletion, Some(5), None)])]); - let attribution = Attribution::build(None, Some(&staged)); - assert!(attribution.unstaged_adds.is_empty()); - assert!(attribution.staged_dels.contains(&5)); - } - - #[test] - fn both_absent_yields_two_empty_sets() { - let attribution = Attribution::build(None, None); - assert!(attribution.unstaged_adds.is_empty()); - assert!(attribution.staged_dels.is_empty()); - } - - #[test] - fn multi_hunk_files_union_across_hunks() { - let unstaged = file_with_hunks(vec![ - hunk(vec![line(LineKind::Addition, None, Some(2))]), - hunk(vec![line(LineKind::Addition, None, Some(40))]), - ]); - let staged = file_with_hunks(vec![ - hunk(vec![line(LineKind::Deletion, Some(7), None)]), - hunk(vec![line(LineKind::Deletion, Some(70), None)]), - ]); - let attribution = Attribution::build(Some(&unstaged), Some(&staged)); - assert_eq!( - attribution.unstaged_adds, - HashSet::from([2, 40]), - "adds from every hunk must be unioned, not just the first" - ); - assert_eq!( - attribution.staged_dels, - HashSet::from([7, 70]), - "dels from every hunk must be unioned, not just the first" - ); - } - - #[test] - fn no_cross_contamination_between_add_and_del_axes_at_the_same_lnum() { - // Line 9 is an UNSTAGED addition (new_lnum 9) and, independently, a STAGED deletion - // whose old_lnum happens to also be 9 — the two axes must stay on their own set, and a - // context/other-kind line at a shared lnum on the "wrong" axis must not leak in. - let unstaged = file_with_hunks(vec![hunk(vec![ - line(LineKind::Addition, None, Some(9)), - line(LineKind::Deletion, Some(9), None), // wrong axis for unstaged_adds - ])]); - let staged = file_with_hunks(vec![hunk(vec![ - line(LineKind::Deletion, Some(9), None), - line(LineKind::Addition, None, Some(9)), // wrong axis for staged_dels - ])]); - let attribution = Attribution::build(Some(&unstaged), Some(&staged)); - assert_eq!(attribution.unstaged_adds, HashSet::from([9])); - assert_eq!(attribution.staged_dels, HashSet::from([9])); - // Cross-axis membership is independent: this file has no other lnums at all, so a lookup - // for a line not present on the RIGHT axis (e.g. an add lookup for a lnum that's only a - // staged deletion) must not accidentally match. - assert!(!attribution.add_is_unstaged(100)); - assert!(!attribution.del_is_staged(100)); - } - - #[test] - fn context_lines_never_contribute_to_either_set() { - let unstaged = file_with_hunks(vec![hunk(vec![line(LineKind::Context, Some(1), Some(1))])]); - let staged = file_with_hunks(vec![hunk(vec![line(LineKind::Context, Some(1), Some(1))])]); - let attribution = Attribution::build(Some(&unstaged), Some(&staged)); - assert!(attribution.unstaged_adds.is_empty()); - assert!(attribution.staged_dels.is_empty()); - } -} diff --git a/git-workon-review/src/clipboard.rs b/git-workon-review/src/clipboard.rs new file mode 100644 index 00000000..14fc58c0 --- /dev/null +++ b/git-workon-review/src/clipboard.rs @@ -0,0 +1,147 @@ +//! OSC 52 clipboard writes (in-diff navigation, `copy-lines`/`copy-location`). +//! +//! The only clipboard mechanism this crate has: an OSC 52 "set clipboard" escape sequence +//! written to the controlling tty. No `arboard`-style dependency — the locked decision that +//! clipboard writes go through OSC 52 only rejected both a pure-`arboard` approach (dependency +//! tree, dead over SSH) and an `arboard`-with-OSC-52-fallback hybrid (two code paths for one +//! keybinding, which the CLAUDE.md "simplicity wins" rule doesn't allow without a concrete +//! reason). `base64_encode` below hand-rolls the small amount of base64 OSC 52 needs rather +//! than pulling in a crate for it. +//! +//! ## Fire-and-forget, by protocol +//! +//! OSC 52 has no reply: a terminal that honors it just updates its clipboard silently, and one +//! that doesn't either ignores the sequence or (rarely) echoes stray bytes if some intermediate +//! layer mishandles it — either way, nothing comes back on the wire to tell the caller which +//! happened. [`write_osc52`] returning `Ok(())` therefore means only "the bytes reached +//! `/dev/tty`", never "the clipboard actually changed". [`crate::app::App::copy_lines`]'s and +//! [`crate::app::App::copy_location`]'s shared footer notice is worded to match: "copied ... to +//! clipboard", never "clipboard updated" — +//! the latter phrasing is a claim a silent failure could falsify. +//! +//! ## Known gaps (deliberately deferred) +//! +//! - **Terminal.app does not implement OSC 52 at all.** The write reaches the tty and is +//! silently swallowed; there is no way to detect this from here. +//! - **tmux only forwards OSC 52 to the outer terminal when `set -g set-clipboard on`** is set +//! in `tmux.conf`. Without it, tmux eats the sequence itself. +//! +//! Neither is fixable from this call site alone, and the target environment (Kitty, no tmux, no +//! SSH) doesn't hit either — so both are logged here as the discoverable next step (a fallback +//! mechanism behind [`write_osc52`]) rather than worked around now. +//! +//! ## Why this write skips `terminal_query.rs`'s tty discipline +//! +//! [`crate::terminal_query`]'s module doc documents hard-won rules for talking to `/dev/tty`: +//! non-blocking reads, a hard deadline, always-restore `termios`. Those rules exist because that +//! probe READS a reply under a raw-mode tty it does not yet own (it runs before `tui::run` +//! installs raw mode/alt screen). This call is a pure write, with no reply to wait for, running +//! AFTER the TUI has already put the tty in raw mode and owns it for the session — there is +//! nothing to save/restore and no deadline to bound, so none of that machinery applies. The one +//! rule that does carry over: never leave the tty in a different state than found. A write of a +//! newline-free escape sequence can't perturb canonical/raw mode or echo (those govern how input +//! is read back, not how output is written), so simply opening, writing, and closing `/dev/tty` +//! satisfies that rule for free. + +use std::io; + +const BASE64_ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Base64-encode `data` with the standard alphabet and `=` padding (RFC 4648 section 4) — what +/// OSC 52's payload requires. Hand-rolled rather than a dependency; see the module doc. +pub(crate) fn base64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b0 = chunk[0]; + let b1 = chunk.get(1).copied().unwrap_or(0); + let b2 = chunk.get(2).copied().unwrap_or(0); + let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2); + out.push(BASE64_ALPHABET[((n >> 18) & 0x3f) as usize] as char); + out.push(BASE64_ALPHABET[((n >> 12) & 0x3f) as usize] as char); + out.push(if chunk.len() > 1 { + BASE64_ALPHABET[((n >> 6) & 0x3f) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + BASE64_ALPHABET[(n & 0x3f) as usize] as char + } else { + '=' + }); + } + out +} + +/// Wrap `payload` as an OSC 52 "set clipboard" escape sequence: `ESC ] 52 ; c ; ESC \`. +/// `c` selects the system clipboard (as opposed to OSC 52's `p`/`q` primary/secondary selection +/// targets, which this crate never uses). Returns the raw bytes ready to write to a tty. +pub(crate) fn osc52_sequence(payload: &str) -> Vec { + let b64 = base64_encode(payload.as_bytes()); + let mut seq = Vec::with_capacity(b64.len() + 8); + seq.push(0x1b); // ESC + seq.extend_from_slice(b"]52;c;"); + seq.extend_from_slice(b64.as_bytes()); + seq.push(0x1b); // ST (String Terminator) part 1 + seq.push(b'\\'); // ST part 2 + seq +} + +/// Write an OSC 52 "set clipboard" sequence for `payload` to the controlling tty. See the module +/// doc for why this is fire-and-forget and why it doesn't touch `termios`. The `Err` case this +/// CAN detect and surface is real: `/dev/tty` failing to open (no controlling terminal at all, +/// e.g. output piped somewhere unusual) or the write itself failing — as opposed to the terminal +/// silently declining to honor a sequence that reached it, which is undetectable by design. +#[cfg(unix)] +pub(crate) fn write_osc52(payload: &str) -> io::Result<()> { + use std::io::Write; + let mut tty = std::fs::File::options().write(true).open("/dev/tty")?; + tty.write_all(&osc52_sequence(payload))?; + tty.flush() +} + +#[cfg(not(unix))] +pub(crate) fn write_osc52(_payload: &str) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "OSC 52 clipboard write is only implemented on unix", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_encode_pads_per_rfc_4648() { + // The three padding cases: 0, 1, 2 bytes of trailing padding. + assert_eq!(base64_encode(b"foo"), "Zm9v"); // 3 bytes, no padding + assert_eq!(base64_encode(b"fo"), "Zm8="); // 2 bytes, one pad + assert_eq!(base64_encode(b"f"), "Zg=="); // 1 byte, two pads + assert_eq!(base64_encode(b""), ""); + } + + #[test] + fn base64_encode_matches_a_realistic_path_line_payload() { + assert_eq!(base64_encode(b"src/app.rs:42"), "c3JjL2FwcC5yczo0Mg=="); + } + + /// Assert the exact byte sequence, not terminal behavior (per the copy `path:line` handoff) + /// — this is the wire format every OSC-52-aware terminal parses, so any drift here is a + /// real regression. + #[test] + fn osc52_sequence_wraps_base64_payload_in_esc_bracket_st() { + let seq = osc52_sequence("foo"); + let mut expected = vec![0x1b]; + expected.extend_from_slice(b"]52;c;Zm9v"); + expected.push(0x1b); + expected.push(b'\\'); + assert_eq!(seq, expected); + } + + #[test] + fn osc52_sequence_encodes_a_path_line_payload() { + let seq = osc52_sequence("src/app.rs:42"); + assert_eq!(seq, b"\x1b]52;c;c3JjL2FwcC5yczo0Mg==\x1b\\".to_vec()); + } +} diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index 10128cbe..912901be 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -9,9 +9,11 @@ //! //! ## Status //! -//! CS1 of the everyday-usability pass (see `docs/plans/review-usability-pass.md`): reader -//! infrastructure + typed getters only. Nothing here is wired into rendering or dispatch yet -//! — that's CS2 (keymaps), CS4/CS5/CS6 (theming), and CS7 (view settings). +//! The git-config reader from the everyday-usability pass (see +//! `docs/plans/review-usability-pass.md`): reader infrastructure + typed getters. Every reader +//! here is wired in — `keymap.rs` reads the per-view keymaps, `theme.rs` reads the base16 +//! palette/light-scheme/terminal-derivation theming, and `App::apply_view_config` reads the +//! view-config settings. //! //! ## Configuration keys //! @@ -42,7 +44,6 @@ //! //! [workon "review.diff"] //! layout = split -//! zoom = combined //! text = syntax ; syntax | tint | edit (default: syntax) //! ``` //! @@ -105,8 +106,8 @@ impl View { /// `workon.review.theme` — see [ADR-035](../../../docs/adr/035-review-theming-base16-hybrid.md). /// -/// `auto` (terminal-derived) is the spec default; the terminal-derivation probe itself is -/// CS6. Until CS6 lands, callers of [`ReviewConfig::theme`] decide how to treat `Auto`. +/// `auto` (terminal-derived) is the spec default; `main.rs` runs the terminal-derivation probe +/// and passes its result to callers of [`ReviewConfig::theme`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Theme { #[default] @@ -117,7 +118,8 @@ pub enum Theme { /// One decomposed `workon.review..bind.` (or bare `workon.review.bind.`) /// config entry: the raw, unparsed value string. Token-grammar parsing (space/reserved-word/ -/// modifier/chord) is CS2's job — see [ADR-034](../../../docs/adr/034-review-git-native-config-schema.md). +/// modifier/chord) is left for the configurable per-view keymaps to do — see +/// [ADR-034](../../../docs/adr/034-review-git-native-config-schema.md). #[derive(Debug, Clone, PartialEq, Eq)] pub struct RawBinding { pub view: View, @@ -127,9 +129,10 @@ pub struct RawBinding { pub keys: String, } -/// The four CS7 view-config settings, read raw (unset → `None`) and owned — see +/// The four view-config settings, read raw (unset → `None`) and owned — see /// [`ReviewConfig::view_config`]. Validation (range/enum checks) and default fallback are -/// [`crate::app::App::apply_view_config`]'s job, same division as [`RawBinding`]/CS2. +/// [`crate::app::App::apply_view_config`]'s job, the same raw-vs-validated split +/// [`RawBinding`] draws for the configurable per-view keymaps. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RawViewConfig { pub outline_width: Option, @@ -137,7 +140,6 @@ pub struct RawViewConfig { pub outline_order: Option, pub icons: Option, pub diff_layout: Option, - pub diff_zoom: Option, pub diff_text: Option, } @@ -211,8 +213,9 @@ pub fn resolve_runtime(repo: &Repository, ctx: &PaletteContext) -> RuntimeConfig /// bare `workon.review.bind.` is the global keymap; `workon.review..bind.` /// is a per-view keymap entry. Returns `None` for anything else under `workon.review.*` /// (`theme`, a view setting, or an unrecognized shape) — not this reader's job to -/// validate/warn on unknown bind shapes; that's CS2's collision/unknown-action validation -/// pass. View settings and `theme` are read directly by their own getters, not through this. +/// validate/warn on unknown bind shapes; that's the configurable per-view keymaps' own +/// collision/unknown-action validation pass. View settings and `theme` are read directly by +/// their own getters, not through this. fn parse_bind_key(name: &str) -> Option<(View, String)> { let rest = name.strip_prefix("workon.review.")?; let parts: Vec<&str> = rest.split('.').collect(); @@ -308,7 +311,6 @@ const KNOWN_SCALAR_KEYS: &[&str] = &[ "outline.mode", "outline.order", "diff.layout", - "diff.zoom", "diff.text", ]; @@ -404,8 +406,8 @@ impl<'repo> ReviewConfig<'repo> { Ok(theme) } - /// Read every `workon.review.theme.*` variable — the CS1 user-configurable colors tier (see - /// [`crate::theme::ThemeOverrides`]) — into a [`ThemeOverrides`] plus any warnings for + /// Read every `workon.review.theme.*` variable — the user-configurable color-override keys + /// (see [`crate::theme::ThemeOverrides`]) — into a [`ThemeOverrides`] plus any warnings for /// malformed values. Deliberately separate from [`ReviewConfig::theme`]: `theme` and /// `theme.*` are different subsections (`[workon "review"] theme = dark` vs. /// `[workon "review.theme"] base00 = …`) and coexist fine — see the module doc's example. @@ -477,7 +479,8 @@ impl<'repo> ReviewConfig<'repo> { /// with git's native precedence (local > global > system) applied. /// /// Token-grammar parsing (space/reserved-word/modifier/chord), unknown-action validation, - /// and collision detection are CS2's job — this is the raw read only. + /// and collision detection are left for the configurable per-view keymaps to do — this is + /// the raw read only. pub fn bindings(&self) -> Result, git2::Error> { let config = self.repo.config()?; // Gather each (view, action) once with its fully-qualified key name. The `entries()` @@ -508,7 +511,7 @@ impl<'repo> ReviewConfig<'repo> { } /// Get `workon.review.outline.width`, raw. `None` if unset — callers apply the current - /// hardcoded default (CS7). + /// hardcoded default (the view-config settings). pub fn outline_width(&self) -> Result, git2::Error> { self.get_view_i64(View::Outline, "width") } @@ -539,19 +542,14 @@ impl<'repo> ReviewConfig<'repo> { self.get_view_string(View::Diff, "layout") } - /// Get `workon.review.diff.zoom`, raw. `None` if unset. - pub fn diff_zoom(&self) -> Result, git2::Error> { - self.get_view_string(View::Diff, "zoom") - } - /// Get `workon.review.diff.text`, raw. `None` if unset — see - /// [ADR-035](../../../docs/adr/035-review-theming-base16-hybrid.md)'s "Revised (CS11, diff + /// [ADR-035](../../../docs/adr/035-review-theming-base16-hybrid.md)'s "Revised (diff /// foreground/background split)" section. pub fn diff_text(&self) -> Result, git2::Error> { self.get_view_string(View::Diff, "text") } - /// Read all four CS7 view-config settings at once into an owned [`RawViewConfig`], + /// Read all four view-config settings at once into an owned [`RawViewConfig`], /// collapsing a config-read error to `None` — same as every other getter here, `App`'s /// resolution (`App::apply_view_config`) treats an unset setting and a failed read /// identically (both apply the current hardcoded default). Exists so `main.rs` can read @@ -565,15 +563,14 @@ impl<'repo> ReviewConfig<'repo> { outline_order: self.outline_order().ok().flatten(), icons: self.icons().ok().flatten(), diff_layout: self.diff_layout().ok().flatten(), - diff_zoom: self.diff_zoom().ok().flatten(), diff_text: self.diff_text().ok().flatten(), } } /// Build the fully-qualified `workon.review.` key for a scalar (non-`bind`, /// non-`theme.*`) setting, `debug_assert!`ing `suffix` is listed in [`KNOWN_SCALAR_KEYS`] — - /// see that constant's doc comment for why this assert is the drift guard for Decision 3's - /// registry. + /// see that constant's doc comment for why this assert is the drift guard for the + /// known-key-drift-test-is-mandatory registry. fn scalar_key(suffix: &str) -> String { debug_assert!( KNOWN_SCALAR_KEYS.contains(&suffix), @@ -808,7 +805,6 @@ mod tests { .config("workon.review.outline.order", "base-first") .config("workon.review.icons", "nerd") .config("workon.review.diff.layout", "split") - .config("workon.review.diff.zoom", "staged") .config("workon.review.diff.text", "tint") .build() .expect("fixture build"); @@ -829,10 +825,6 @@ mod tests { config.diff_layout().expect("layout"), Some("split".to_string()) ); - assert_eq!( - config.diff_zoom().expect("zoom"), - Some("staged".to_string()) - ); assert_eq!(config.diff_text().expect("text"), Some("tint".to_string())); } @@ -847,7 +839,6 @@ mod tests { assert_eq!(config.outline_order().expect("order"), None); assert_eq!(config.icons().expect("icons"), None); assert_eq!(config.diff_layout().expect("layout"), None); - assert_eq!(config.diff_zoom().expect("zoom"), None); assert_eq!(config.diff_text().expect("text"), None); } @@ -888,7 +879,7 @@ mod tests { fn theme_overrides_reads_the_cursor_unfocused_bg_tint_key() { use crate::theme::Palette; - // CS1 (`unfocused-cursor-wash`): `cursor-unfocused-bg` replaced the outline-only + // `unfocused-cursor-wash`: `cursor-unfocused-bg` replaced the outline-only // `outline-cursor-unfocused-bg` key with no backward compatibility — see the sibling // rejection test below for the dropped old key. let fixture = FixtureBuilder::new() @@ -912,10 +903,10 @@ mod tests { #[test] fn theme_overrides_reads_the_search_match_bg_tint_key() { - // M11 CS3 (`diff-search`): `search-match-bg`/`search-current-bg` are brand-new tints with - // no scheme slot fallback — this is the ONLY way to set them (see `tint_slot`'s doc - // comment), and they must NOT be in `KNOWN_SCALAR_KEYS` (the open-ended `theme.*` - // subspace already covers them for the unknown-key warning). + // The in-diff search (`diff-search`): `search-match-bg`/`search-current-bg` are + // brand-new tints with no scheme slot fallback — this is the ONLY way to set them (see + // `tint_slot`'s doc comment), and they must NOT be in `KNOWN_SCALAR_KEYS` (the + // open-ended `theme.*` subspace already covers them for the unknown-key warning). use crate::theme::Palette; let fixture = FixtureBuilder::new() @@ -1038,8 +1029,8 @@ mod tests { .expect("theme_overrides"); assert!(overrides.is_empty(), "invalid value must not set the slot"); assert_eq!(warnings.len(), 1); - // Full-message pin (config-validation-completeness Decision 5): names the expected - // format, and `ignoring` with no fallback — an ignored override has none. + // Full-message pin (invalid-value warnings name the allowed set and the fallback): names + // the expected format, and `ignoring` with no fallback — an ignored override has none. assert_eq!( warnings[0], "workon.review.theme.base00: invalid color \"not-a-color\" \ @@ -1315,7 +1306,6 @@ mod tests { .config("workon.review.outline.mode", "tree") .config("workon.review.outline.order", "base-first") .config("workon.review.diff.layout", "split") - .config("workon.review.diff.zoom", "staged") .config("workon.review.diff.text", "tint") .config("workon.review.theme.base00", "#101010") // a theme slot .config("workon.review.theme.cursor-bg", "#1a2b3c") // a theme tint @@ -1331,8 +1321,28 @@ mod tests { assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); } - /// Decision 3's drift test. `KNOWN_SCALAR_KEYS` is a second source of truth alongside the - /// getters that actually read `workon.review.*` — this enumerates every scalar getter, sets + /// ADR-038, "Remove `workon.review.diff.zoom`": it was removed with no replacement key and no + /// compatibility alias, so a config still setting it now degrades exactly like any other + /// unrecognized key — one unknown-key warning, no crash, no effect — rather than silently + /// applying a dead setting. + #[test] + fn unknown_key_warnings_flags_the_removed_diff_zoom_key() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.zoom", "combined") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let warnings = ReviewConfig::new(repo) + .unknown_key_warnings() + .expect("unknown_key_warnings"); + assert_eq!(warnings.len(), 1, "got: {warnings:?}"); + assert!(warnings[0].contains("diff.zoom"), "got: {warnings:?}"); + } + + /// The known-key-drift-test-is-mandatory test. `KNOWN_SCALAR_KEYS` is a second source of truth + /// alongside the getters that actually read `workon.review.*` — this enumerates every scalar + /// getter, sets /// its documented key on a fixture, and asserts BOTH that the getter reads it back AND that /// the same key is claimed by the registry (`is_claimed`), so a getter added without a /// matching registry entry fails here (not just "the registry agrees with itself"). The @@ -1347,7 +1357,6 @@ mod tests { .config("workon.review.outline.mode", "tree") .config("workon.review.outline.order", "base-first") .config("workon.review.diff.layout", "split") - .config("workon.review.diff.zoom", "staged") .config("workon.review.diff.text", "tint") .build() .expect("fixture build"); @@ -1373,7 +1382,6 @@ mod tests { "diff.layout", config.diff_layout().expect("layout").is_some(), ), - ("diff.zoom", config.diff_zoom().expect("zoom").is_some()), ("diff.text", config.diff_text().expect("text").is_some()), ]; diff --git a/git-workon-review/src/error.rs b/git-workon-review/src/error.rs index a1f9edea..e869da4b 100644 --- a/git-workon-review/src/error.rs +++ b/git-workon-review/src/error.rs @@ -75,8 +75,9 @@ pub enum SynthesisError { #[diagnostic(code(workon::review::hunk_out_of_range))] HunkOutOfRange { path: String, index: usize }, - /// The file's status can't be expressed as a hunk patch (trap 3: whole-file ops route - /// around synthesis entirely; this is what a caller sees if it reaches synthesis anyway). + /// The file's status can't be expressed as a hunk patch (whole-file-ops fallback: whole-file + /// ops route around synthesis entirely; this is what a caller sees if it reaches synthesis + /// anyway). #[error("line-precise selection is not supported for '{path}' ({status:?})")] #[diagnostic( code(workon::review::line_selection_unsupported), @@ -114,7 +115,8 @@ pub enum ApplyError { #[diagnostic(code(workon::review::git_spawn_failed))] GitSpawn(#[source] std::io::Error), - /// A whole-file operation's filesystem I/O failed (`file_ops.rs`, CS4). + /// A whole-file operation's filesystem I/O failed (`file_ops.rs`, the whole-file ops and + /// routing layer). #[error("file operation on '{path}' failed")] #[diagnostic(code(workon::review::file_op_io))] Io { diff --git a/git-workon-review/src/file_ops.rs b/git-workon-review/src/file_ops.rs index 0f7ef370..ec876708 100644 --- a/git-workon-review/src/file_ops.rs +++ b/git-workon-review/src/file_ops.rs @@ -1,5 +1,5 @@ -//! Whole-file operations (trap 3): the staging verbs a hunk patch cannot express, because a -//! hunk patch always has BOTH a pre-image and a post-image to diff between. Creations, +//! Whole-file operations (whole-file-ops fallback): the staging verbs a hunk patch cannot express, +//! because a hunk patch always has BOTH a pre-image and a post-image to diff between. Creations, //! deletions, and untracked files each have only one side — synthesizing a hunk patch for them //! either has no preimage to apply against (untracked: git rejects it) or stages an EMPTY BLOB //! instead of removing the file (deleted: git happily accepts a patch that deletes every line @@ -18,7 +18,7 @@ use crate::error::ApplyError; /// /// The choice is made by checking the working tree on disk, not by trusting a `FileStatus` /// passed in by the caller — the two can only usefully agree once the check runs, so the check -/// is the source of truth (trap 3's core fix). +/// is the source of truth (the whole-file-ops fallback's core fix). /// /// The presence check uses `symlink_metadata` (lstat), NOT `Path::exists` (which follows /// symlinks and reports `false` for a broken one). An untracked BROKEN symlink is still a real diff --git a/git-workon-review/src/highlight.rs b/git-workon-review/src/highlight.rs index 365c4fe0..dde7a4c7 100644 --- a/git-workon-review/src/highlight.rs +++ b/git-workon-review/src/highlight.rs @@ -69,7 +69,8 @@ pub fn capture_index(name: &str) -> Option { /// Maps a file extension to the [`build_config`]/[`language_for_key`] key for its grammar, or /// `None` when no bundled grammar covers it. `pub(crate)` so [`crate::app`] can resolve a gap's -/// anchor file to a scope-lookup language (CS9) without duplicating this table. +/// anchor file to a scope-lookup language (tree-sitter scope reveal) without duplicating this +/// table. pub(crate) fn lang_key_for_ext(ext: &str) -> Option<&'static str> { match ext { "rs" => Some("rust"), diff --git a/git-workon-review/src/icons.rs b/git-workon-review/src/icons.rs index 086a9aea..e70f709a 100644 --- a/git-workon-review/src/icons.rs +++ b/git-workon-review/src/icons.rs @@ -1,5 +1,6 @@ -//! CS5's opt-in nerd-font file-type icon table — a pure module, no [`crate::app::App`]/ -//! [`crate::outline`] dependency, mirroring [`crate::summary`]'s pure-module posture. +//! The opt-in nerd-font file-type icon table (part of file-status letters and opt-in nerd +//! icons) — a pure module, no [`crate::app::App`]/[`crate::outline`] dependency, mirroring +//! [`crate::summary`]'s pure-module posture. //! //! A terminal cannot report which font (patched with the nerd-font private-use glyphs or not) //! the user has configured, so there is NO auto-detection here or anywhere else in the crate — @@ -7,15 +8,15 @@ //! doc block and `App::apply_view_config`). With the config left at its default (`none`), //! nothing in this module is ever called from `render.rs`. //! -//! **Icon table (CS1 polish pass):** per-file glyphs and brand colors are looked up via the +//! **The devicons-backed icon table:** per-file glyphs and brand colors are looked up via the //! [`devicons`] crate (Apache-2.0, `alexpasmantier/devicons`) rather than a hand-rolled table — //! 597 filename+extension entries with the same filename-before-extension precedence //! [`icon_for_path`] already followed. devicons ships separate Dark/Light color maps; the caller //! picks one from the active [`crate::theme::Palette`] (see [`icon_for_path`]'s doc comment). //! **Nerd-font v3 requirement:** devicons' glyphs are drawn from nerd-font v3's private-use //! codepoints, roughly a fifth of which sit in a Unicode supplementary plane (outside the BMP). -//! The crate's own `IconMode::Nerd` glyphs picked in CS3 (status/header markers) stay -//! BMP-only for wider font compatibility, but a per-file icon from devicons may require a v3 +//! The crate's own `IconMode::Nerd` glyphs picked for the nerd-mode status and header glyphs +//! stay BMP-only for wider font compatibility, but a per-file icon from devicons may require a v3 //! nerd-font — this is the same "no auto-detection" opt-in tradeoff as the rest of this module. //! devicons does not cover directories (it is a per-file mapper), so [`DIR_ICON`] is still ours. @@ -30,7 +31,8 @@ use ratatui::style::Color; /// the summary panel's glyphs, and the winbar's marker/diffstat/file icons alike. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum IconMode { - /// No icon glyph — today's plain `[glyph][letter] path` row (CS5's unconditional part only). + /// No icon glyph — today's plain `[glyph][letter] path` row (the unconditional part of + /// file-status letters and opt-in nerd icons only). #[default] None, /// A nerd-font private-use glyph per file extension (falling back to diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index fdcd868f..366b7916 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -52,7 +52,7 @@ pub enum Command { ScrollTop, ScrollBottom, ToggleLayout, - CycleZoom, + ToggleMaximize, ToggleSplitFocus, Refresh, StageHunk, @@ -75,6 +75,8 @@ pub enum Command { Search, SearchNext, SearchPrev, + CopyLines, + CopyLocation, // Diff view. FocusOutline, // Outline view. @@ -98,7 +100,7 @@ pub enum Command { /// One row of the action registry: a [`Command`] with its stable config identity (`view` + /// `name`), the default key tokens that reproduce the pre-config hardcoded binding, and a human -/// description (the help overlay in CS3 renders from this). +/// description (the help footer and `?` overlay renders from this). #[derive(Debug, Clone, Copy)] pub struct Registered { pub command: Command, @@ -196,16 +198,19 @@ pub static REGISTRY: &[Registered] = &[ description: "Toggle side-by-side / inline layout", }, Registered { - command: Command::CycleZoom, + command: Command::ToggleMaximize, view: View::Diff, - name: "cycle-zoom", + name: "toggle-maximize", // Rebound from `z` (diff-fold-keys): `z` now anchors the `zM`/`zR` gap fold-all chords in // this view, and a bare-key binding can't coexist with a longer chord sharing its prefix - // (see `shift_z_dispatches_cycle_zoom_with_no_collisions`'s doc comment for the + // (see `shift_z_dispatches_toggle_maximize_with_no_collisions`'s doc comment for the // mechanics; `build_context`'s prefix-clash check would now warn on this, not just - // silently break dispatch). `Z` was free in `View::Diff`. + // silently break dispatch). `Z` was free in `View::Diff`. ADR-038, "Rename the keymap + // action `cycle-zoom` to `toggle-maximize`": renamed from `cycle-zoom`, keeping the `Z` + // binding — a user config still naming `cycle-zoom` + // degrades to the keymap's unknown-action warning rather than silently doing nothing. default_keys: "Z", - description: "Cycle the staged/unstaged zoom", + description: "Maximize/restore the focused split pane", }, Registered { command: Command::ToggleSplitFocus, @@ -274,7 +279,8 @@ pub static REGISTRY: &[Registered] = &[ command: Command::NextHunk, view: View::Diff, name: "next-hunk", - // `n` moved off this default (M11 CS3, `diff-search`): it's now `search-next`'s default, + // `n` moved off this default (the in-diff search, `diff-search`): it's now + // `search-next`'s default, // which itself falls back to this exact action when no search is active — see that row's // description. `]h` alone still reaches it directly. default_keys: "]h", @@ -371,6 +377,20 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "N", description: "Previous search match (or previous hunk, when no search is active)", }, + Registered { + command: Command::CopyLines, + view: View::Diff, + name: "copy-lines", + default_keys: "y", + description: "Copy the selected (or cursor) lines' text to the clipboard", + }, + Registered { + command: Command::CopyLocation, + view: View::Diff, + name: "copy-location", + default_keys: "Y", + description: "Copy path:line (or path:lo-hi) for the selected rows to the clipboard", + }, // ── Outline view ───────────────────────────────────────────────────────── Registered { command: Command::OutlineDown, @@ -676,7 +696,7 @@ pub struct Keymap { /// Active bindings when the outline has focus: global ∪ outline. outline: Vec<(KeySeq, Command)>, /// Resolved key sequences per registry row (parallel to [`REGISTRY`]) — the source for the - /// help overlay's "current keys for this action" (CS3). + /// help overlay's "current keys for this action" (the help footer and `?` overlay). resolved: Vec>, /// Config problems collected during resolution (unknown action names, key collisions) — the /// caller surfaces these through the footer-notice mechanism at startup. @@ -900,8 +920,9 @@ pub struct HelpSection { /// Build the help overlay's content for `focused` (the view with keyboard focus — [`View::Diff`] /// or [`View::Outline`]; never [`View::Global`]): a "Global" section, then the focused view's own -/// section, each listing only BOUND actions (an action with no resolved keys — user-unbound — is -/// skipped, per CS3). Pure and `Keymap`-driven — the display never hardcodes a key string, so a +/// section, each listing only BOUND actions (an action with no resolved keys — user-unbound — +/// is skipped, per the help footer and `?` overlay). Pure and `Keymap`-driven — the display +/// never hardcodes a key string, so a /// rebind shows here automatically. pub fn help_sections(keymap: &Keymap, focused: View) -> Vec { vec![ @@ -961,7 +982,34 @@ enum HintItem { Pair(Command, Command, &'static str), } -/// CS4 (`outline-mode-cycle`): most hint labels are the static string baked into the `HintItem`, +impl HintItem { + /// Whether this entry advertises a staging verb, and so drops out of the hint where + /// `App::can_stage_current` is false — see [`footer_hint`]. + /// + /// Matched on the COMMAND, not on the entry's position in `DIFF_HINTS`, so a staging verb + /// added to a curated set later is covered without a second edit here. A `Pair` counts when + /// either half does; no staging command is half of a pair today, and one that were would + /// still be a staging entry. + fn is_staging(&self) -> bool { + fn staging(command: Command) -> bool { + matches!( + command, + Command::StageHunk + | Command::StageFile + | Command::DiscardHunk + | Command::DiscardFile + | Command::OutlineStage + | Command::OutlineDiscard + ) + } + match self { + HintItem::One(command, _) => staging(*command), + HintItem::Pair(a, b, _) => staging(*a) || staging(*b), + } + } +} + +/// `outline-mode-cycle`: most hint labels are the static string baked into the `HintItem`, /// but `OutlineCycleMode`'s label shows the mode `i` would switch TO instead — computed from /// `outline_mode` (the outline's CURRENT mode, so this is `outline_mode.cycle()`'s label). fn render_hint_item(keymap: &Keymap, item: &HintItem, outline_mode: OutlineMode) -> Option { @@ -985,8 +1033,9 @@ fn render_hint_item(keymap: &Keymap, item: &HintItem, outline_mode: OutlineMode) } } -/// The diff view's curated footer hint set (locked design in CS3): nav, stage/discard, outline, -/// help, quit — ~5-7 entries picked to make the tool feel learnable, not an exhaustive list. +/// The diff view's curated footer hint set (locked design from the help-footer-and-`?`-overlay +/// work): nav, stage/discard, outline, help, quit — ~5-7 entries picked to make the tool feel +/// learnable, not an exhaustive list. const DIFF_HINTS: &[HintItem] = &[ HintItem::Pair(Command::CursorDown, Command::CursorUp, "move"), HintItem::One(Command::StageHunk, "stage"), @@ -996,7 +1045,8 @@ const DIFF_HINTS: &[HintItem] = &[ HintItem::One(Command::Quit, "quit"), ]; -/// The outline view's curated footer hint set (locked design in CS3). +/// The outline view's curated footer hint set (locked design from the help-footer-and-`?`-overlay +/// work). const OUTLINE_HINTS: &[HintItem] = &[ HintItem::Pair(Command::OutlineDown, Command::OutlineUp, "move"), HintItem::One(Command::OutlineConfirm, "open"), @@ -1016,7 +1066,18 @@ const OUTLINE_HINTS: &[HintItem] = &[ /// string, so a rebind shows here too. A notice temporarily replaces this in the footer (the /// caller's job, see `render::render_footer`); an unbound curated action is simply dropped from /// the string rather than leaving a stale/wrong key visible. -pub fn footer_hint(keymap: &Keymap, focused: View, outline_mode: OutlineMode) -> String { +/// +/// `can_stage` is `App::can_stage_current` — false where a staging verb can only refuse (a +/// committed changeset, a binary file, an empty file list). The staging entries drop out of the +/// string entirely there, the same way an unbound action does, so the hint never advertises a +/// key that will answer with a refusal notice. The keys stay BOUND and still explain themselves +/// when pressed: this hides the advertisement, not the behavior. +pub fn footer_hint( + keymap: &Keymap, + focused: View, + outline_mode: OutlineMode, + can_stage: bool, +) -> String { let items: &[HintItem] = match focused { View::Diff => DIFF_HINTS, View::Outline => OUTLINE_HINTS, @@ -1024,6 +1085,7 @@ pub fn footer_hint(keymap: &Keymap, focused: View, outline_mode: OutlineMode) -> }; items .iter() + .filter(|item| can_stage || !item.is_staging()) .filter_map(|item| render_hint_item(keymap, item, outline_mode)) .collect::>() .join(" \u{b7} ") @@ -1232,8 +1294,9 @@ mod tests { } /// `l`/`right` are free in the Diff view (they're only bound in the Outline view, to - /// `focus-diff`) — the handoff's locked decision #2 reuses them for `hscroll-right` there, - /// mirroring the Outline view's `l`/`right` = focus-diff. + /// `focus-diff`) — the hscroll handoff's locked decision that `h`/`left` pans back to column + /// 0 before focusing the outline reuses them for `hscroll-right` there, mirroring the + /// Outline view's `l`/`right` = focus-diff. #[test] fn l_and_right_dispatch_hscroll_right_in_the_diff_view() { let km = Keymap::defaults(); @@ -1291,9 +1354,10 @@ mod tests { ); } - /// CS3 (diff-fold-keys) originally bound `n` as an extra default on `next-hunk`, for symmetry - /// with the outline's `n`/`p` changeset nav. M11 CS3 (`diff-search`) reclaims `n` as - /// `search-next`'s default instead (falling back to `next-hunk` itself when no search is + /// The gap-reset/expand-all-keys work (`diff-fold-keys`) originally bound `n` as an extra + /// default on `next-hunk`, for symmetry with the outline's `n`/`p` changeset nav. The + /// in-diff search (`diff-search`) reclaims `n` as `search-next`'s default instead (falling + /// back to `next-hunk` itself when no search is /// active — `App::search_next` — so `n`'s PRACTICAL effect on an unbound-search diff is /// unchanged); `p` is untouched, still `prev-hunk`'s extra default. `primary_key` still picks /// the first token, so the footer/help keep showing `]h`/`[h` for `next-hunk`/`prev-hunk` @@ -1318,37 +1382,38 @@ mod tests { ); } - /// CS3 (diff-fold-keys): `cycle-zoom` was rebound from bare `z` to `Z` to make room for the - /// `zM`/`zR` gap fold-all chords in `View::Diff`. This wasn't optional bookkeeping — - /// `match_keys` gives a strict-prefix match precedence over an exact one in the SAME scan - /// (see its doc comment): had `cycle-zoom` stayed on bare `z` alongside `zM`/`zR`, a lone `z` - /// press would always report `Pending` instead of firing `CycleZoom` immediately, and any - /// follow-up key that wasn't `M`/`R` would be swallowed as `Unmatched { mid_sequence: true }` - /// rather than re-processed — silently breaking `cycle-zoom` with no *runtime* warning; the - /// matcher's chord-wins precedence never changed. This test pins the resolved state: `Z` - /// fires `CycleZoom` immediately, and `z` only - /// ever anchors the `zM`/`zR` chords below — never a bare-key command of its own again. + /// The gap-reset/expand-all-keys work (`diff-fold-keys`): `toggle-maximize` (named + /// `cycle-zoom` before ADR-038) was rebound + /// from bare `z` to `Z` to make room for the `zM`/`zR` gap fold-all chords in `View::Diff`. + /// This wasn't optional bookkeeping — `match_keys` gives a strict-prefix match precedence + /// over an exact one in the SAME scan (see its doc comment): had it stayed on bare `z` + /// alongside `zM`/`zR`, a lone `z` press would always report `Pending` instead of firing + /// `ToggleMaximize` immediately, and any follow-up key that wasn't `M`/`R` would be swallowed + /// as `Unmatched { mid_sequence: true }` rather than re-processed — silently breaking the + /// binding with no *runtime* warning; the matcher's chord-wins precedence never changed. This + /// test pins the resolved state: `Z` fires `ToggleMaximize` immediately, and `z` only ever + /// anchors the `zM`/`zR` chords below — never a bare-key command of its own again. /// (`build_context` now also flags this shape as a prefix clash and warns — see /// `a_bare_prefix_binding_warns_about_the_chord_that_shadows_it` below — but the resolved /// defaults must still be clash-free, hence the empty-warnings assertion here.) #[test] - fn shift_z_dispatches_cycle_zoom_with_no_collisions() { + fn shift_z_dispatches_toggle_maximize_with_no_collisions() { let km = Keymap::defaults(); assert!( km.warnings().is_empty(), - "cycle-zoom's rebind to Z must not collide with anything: {:?}", + "toggle-maximize's rebind to Z must not collide with anything: {:?}", km.warnings() ); assert_eq!( feed(&km, false, &[key(KeyCode::Char('Z'))]), - Dispatch::Command(Command::CycleZoom) + Dispatch::Command(Command::ToggleMaximize) ); } /// `zM`/`zR` — reset/expand-all gaps in the diff view (companion to the outline's own /// `zM`/`zR` fold-all, `z_m_and_z_r_dispatch_outline_fold_all_with_no_collisions` above). - /// Coexists cleanly with `Z` (`cycle-zoom`, see the test above) now that `cycle-zoom` no - /// longer claims the bare `z` prefix. + /// Coexists cleanly with `Z` (`toggle-maximize`, see the test above) now that it no longer + /// claims the bare `z` prefix. #[test] fn z_m_and_z_r_dispatch_diff_gap_fold_all_with_no_collisions() { let km = Keymap::defaults(); @@ -1375,6 +1440,30 @@ mod tests { ); } + /// In-diff navigation (`copy-lines`/`copy-location`, the yank split): `y` was free in both + /// `View::Global` and `View::Diff` (unlike `p`, which `[h`'s extra default and the outline's + /// `prev-changeset` already claim), so no existing binding needed to move to make room for + /// it — unlike the `z` -> `Z` rebind above. `Y` is likewise free (verified against + /// every `default_keys` entry in this registry when the yank split was designed). Pins both + /// resolved defaults clash-free the same way those tests do. + #[test] + fn y_dispatches_copy_lines_and_shift_y_dispatches_copy_location_with_no_collisions() { + let km = Keymap::defaults(); + assert!( + km.warnings().is_empty(), + "copy-lines/copy-location's default `y`/`Y` must not collide with anything: {:?}", + km.warnings() + ); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('y'))]), + Dispatch::Command(Command::CopyLines) + ); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('Y'))]), + Dispatch::Command(Command::CopyLocation) + ); + } + #[test] fn a_config_rebind_overrides_the_default() { let km = Keymap::from_bindings(&[RawBinding { @@ -1499,16 +1588,16 @@ mod tests { #[test] fn a_bare_prefix_binding_warns_about_the_chord_that_shadows_it() { - // Rebind cycle-zoom back onto bare `z`, which now collides with the `zM`/`zR` gap + // Rebind toggle-maximize back onto bare `z`, which now collides with the `zM`/`zR` gap // fold-all chords still on their defaults in `View::Diff`. Each chord sharing the `z` - // prefix is its own clashing pair — one warning per pair, both naming cycle-zoom. + // prefix is its own clashing pair — one warning per pair, both naming toggle-maximize. let km = Keymap::from_bindings(&[RawBinding { view: View::Diff, - action: "cycle-zoom".to_string(), + action: "toggle-maximize".to_string(), keys: "z".to_string(), }]); assert_eq!(km.warnings().len(), 2, "warnings: {:?}", km.warnings()); - assert!(km.warnings().iter().all(|w| w.contains("cycle-zoom"))); + assert!(km.warnings().iter().all(|w| w.contains("toggle-maximize"))); assert!(km.warnings().iter().all(|w| w.contains("diff"))); assert!(km.warnings().iter().any(|w| w.contains("reset-gaps"))); assert!(km.warnings().iter().any(|w| w.contains("expand-all-gaps"))); @@ -1572,7 +1661,7 @@ mod tests { ); } - // ── CS3: help overlay / footer hint builders ──────────────────────────── + // ── Help footer and `?` overlay: help overlay / footer hint builders ───── #[test] fn help_sections_groups_global_and_the_focused_view_only() { @@ -1594,9 +1683,10 @@ mod tests { #[test] fn help_sections_cycle_mode_entry_spells_out_the_full_order() { - // CS4: descriptions are static `&'static str`s baked into `REGISTRY`, so the help - // overlay can't mark the CURRENT mode dynamically without a broader refactor — the - // locked fallback is a static full-order description, with the dynamic `→next` shown + // `outline-mode-cycle`: descriptions are static `&'static str`s baked into `REGISTRY`, + // so the help overlay can't mark the CURRENT mode dynamically without a broader + // refactor — the locked fallback is a static full-order description, with the dynamic + // `→next` shown // only in the footer hint (see `footer_hint_outline_cycle_label_tracks_the_current_mode`). let km = Keymap::defaults(); let sections = help_sections(&km, View::Outline); @@ -1653,7 +1743,7 @@ mod tests { #[test] fn footer_hint_renders_the_curated_diff_entries() { let km = Keymap::defaults(); - let hint = footer_hint(&km, View::Diff, OutlineMode::default()); + let hint = footer_hint(&km, View::Diff, OutlineMode::default(), true); assert!(hint.contains("j/k move"), "got: {hint:?}"); assert!(hint.contains("s stage"), "got: {hint:?}"); assert!(hint.contains("d discard"), "got: {hint:?}"); @@ -1662,10 +1752,37 @@ mod tests { assert!(hint.contains("q quit"), "got: {hint:?}"); } + #[test] + fn footer_hint_drops_the_staging_entries_where_staging_can_only_refuse() { + let km = Keymap::defaults(); + let hint = footer_hint(&km, View::Diff, OutlineMode::default(), false); + assert!( + !hint.contains("stage") && !hint.contains("discard"), + "a non-stageable file must not advertise the staging verbs, got: {hint:?}" + ); + // The rest of the curated set is untouched — this hides two entries, not the footer. + assert!(hint.contains("j/k move"), "got: {hint:?}"); + assert!(hint.contains("o outline"), "got: {hint:?}"); + assert!(hint.contains("? help"), "got: {hint:?}"); + assert!(hint.contains("q quit"), "got: {hint:?}"); + } + + /// The outline's curated set carries no staging entry to begin with (it was trimmed to fit + /// 80 columns), so the flag changes nothing there. Pinned so a later addition to + /// `OUTLINE_HINTS` has to decide about `can_stage` rather than inherit an accident. + #[test] + fn footer_hint_outline_entries_are_unaffected_by_the_staging_flag() { + let km = Keymap::defaults(); + assert_eq!( + footer_hint(&km, View::Outline, OutlineMode::Stack, true), + footer_hint(&km, View::Outline, OutlineMode::Stack, false), + ); + } + #[test] fn footer_hint_renders_the_curated_outline_entries() { let km = Keymap::defaults(); - let hint = footer_hint(&km, View::Outline, OutlineMode::Stack); + let hint = footer_hint(&km, View::Outline, OutlineMode::Stack, true); assert!(hint.contains("j/k move"), "got: {hint:?}"); assert!(hint.contains("enter open"), "got: {hint:?}"); assert!(hint.contains("n/p changeset"), "got: {hint:?}"); @@ -1691,7 +1808,7 @@ mod tests { (OutlineMode::Flat, "tree"), (OutlineMode::Tree, "stack"), ] { - let hint = footer_hint(&km, View::Outline, mode); + let hint = footer_hint(&km, View::Outline, mode, true); let want = format!("i \u{2192}{next}"); assert!( hint.contains(&want), @@ -1707,7 +1824,7 @@ mod tests { action: "stage-hunk".to_string(), keys: "x".to_string(), }]); - let hint = footer_hint(&km, View::Diff, OutlineMode::default()); + let hint = footer_hint(&km, View::Diff, OutlineMode::default(), true); assert!(hint.contains("x stage"), "got: {hint:?}"); assert!(!hint.contains("s stage"), "got: {hint:?}"); } @@ -1719,7 +1836,7 @@ mod tests { action: "stage-hunk".to_string(), keys: String::new(), }]); - let hint = footer_hint(&km, View::Diff, OutlineMode::default()); + let hint = footer_hint(&km, View::Diff, OutlineMode::default(), true); assert!(!hint.contains("stage"), "got: {hint:?}"); // The rest of the curated set is unaffected. assert!(hint.contains("d discard"), "got: {hint:?}"); diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index efe3caba..5f16f1f8 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -6,17 +6,17 @@ //! //! ## Status //! -//! M2: the diff model ([`model`]), its acquisition from [`workon::Changeset`]s +//! The diff model ([`model`]), its acquisition from [`workon::Changeset`]s //! ([`acquire`]), patch synthesis ([`synthesis`]), the apply chokepoint ([`apply`]), whole-file //! ops ([`file_ops`]), the patch-vs-file-op routing layer ([`ops`]), the FIFO staging queue //! ([`queue`]), and the refresh generation coordinator ([`refresh`]) exist; the round-trip -//! verdict corpus lands in the next M2 changeset. +//! verdict corpus exists too, in `tests/suite/roundtrip_corpus.rs`. pub mod acquire; pub mod align; pub mod app; pub mod apply; -pub mod attribute; +pub mod clipboard; pub mod config; pub mod error; pub mod file_ops; diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 1789f241..d13e89b8 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -10,7 +10,7 @@ use miette::{IntoDiagnostic, Result}; use workon_review::acquire::{diff_changesets, resolve_changesets}; use workon_review::app::{App, ChangesetView, Severity}; use workon_review::config::{self, ReviewConfig}; -use workon_review::keymap::{self, Command, Keymap}; +use workon_review::keymap::Keymap; use workon_review::source::{complete_source, resolve_source, Source}; use workon_review::terminal_query; use workon_review::theme::{self, Palette}; @@ -38,7 +38,8 @@ struct Cli { fn main() -> Result<()> { // Respond to the `COMPLETE=` dynamic-completion protocol before anything else — mirrors // git-workon's own entry point. Exits early when `COMPLETE` is set; a no-op otherwise. This is - // what lets git-workon delegate `git workon review ` completion here (M6 CS3). + // what lets git-workon delegate `git workon review ` completion here (external- + // subcommand completion enumeration). CompleteEnv::with_factory(Cli::command).complete(); let cli = Cli::parse(); @@ -51,16 +52,19 @@ fn main() -> Result<()> { .into_diagnostic()? .to_string(); - // No `[SOURCE]` argument: the M5 auto-detect entry point (locked decision #7), unchanged — - // the full Graphite stack when one is active, or a single synthetic uncommitted changeset - // otherwise (keeps a non-Graphite repo byte-identical to M2–M4's `diff_uncommitted` path). - // A `[SOURCE]` argument routes through the ADR-036 classifier/resolver instead (M7 CS2/CS3). + // No `[SOURCE]` argument: the stack-and-outline auto-detect entry point (locked + // decision: auto-detect Graphite, else a single uncommitted changeset), unchanged — the + // full Graphite stack when one is active, or a single synthetic uncommitted changeset + // otherwise (keeps a non-Graphite repo byte-identical to the original `diff_uncommitted` + // path). A `[SOURCE]` argument routes through the ADR-036 classifier/resolver instead (the + // stack/uncommitted source keywords and ``-and-range-resolution work). // `source` is kept (not just the resolved changesets) so it can be handed to `App` below — // `App::refresh` re-runs THIS same ask on every refresh rather than downgrading to - // auto-detect (M7 CS2 fix). + // auto-detect (a stack/uncommitted-source-keywords fix). // - // Everything from here through the theme probe runs BEFORE the terminal is taken (CS5's - // splash enters the alternate screen further down, for the diff/build phase only). That + // Everything from here through the theme probe runs BEFORE the terminal is taken (the + // launch splash and early terminal takeover's splash enters the alternate screen further + // down, for the diff/build phase only). That // ordering is deliberate, not incidental: // - PR resolution fetches over the network, and auth-git2 may interactively PROMPT for an // ssh passphrase / https credentials — inside raw-mode alternate screen the prompt would @@ -94,8 +98,9 @@ fn main() -> Result<()> { } // Resolve the palette selection first, before `repo` moves — a config-read error degrades to - // dark rather than aborting the review (CS5). `Auto` runs the terminal-derivation probe (CS6), - // which needs the controlling tty and so lives outside the pure `theme.rs`; it is bounded by a + // dark rather than aborting the review (the launch splash and early terminal takeover). `Auto` + // runs the terminal-derivation probe, which needs the controlling tty and so lives outside + // the pure `theme.rs`; it is bounded by a // hard timeout and always yields a curated fallback on a silent/hostile terminal, never a // hang. `Dark`/`Light`/a read error stay `resolve_runtime`'s own I/O-free ladder below — this // only feeds `auto_base` (what to cache for `PaletteContext`), so a non-`Auto` selection gets @@ -111,8 +116,9 @@ fn main() -> Result<()> { _ => (Palette::dark(), false), }; - // CS2 (`no-color-mono`): read the env kill-switch once here — `resolve_runtime` applies it - // last in its ladder (after resolution AND overrides), so it always wins over an override. + // NO_COLOR monochrome rendering (`no-color-mono`): read the env kill-switch once here — + // `resolve_runtime` applies it last in its ladder (after resolution AND overrides), so it + // always wins over an override. // `FORCE_COLOR` is deliberately not consulted (see `no_color`'s doc comment). let no_color_env = no_color(std::env::var_os("NO_COLOR").as_deref()); if no_color_env { @@ -156,7 +162,8 @@ fn main() -> Result<()> { terminal_query::flush_pending_tty_input(); } - // CS5: take the terminal while the diffs build — on a deep stack this used to be the bulk of + // The launch splash and early terminal takeover: take the terminal while the diffs build — + // on a deep stack this used to be the bulk of // the launch with the terminal dead the whole time. Everything that could print, prompt, or // flush is done (see the block comment above the resolve), so from here the terminal belongs // to the TUI. `Tui`'s Drop restores it, so the `?`s below put the shell back before miette @@ -195,7 +202,7 @@ fn main() -> Result<()> { // `App::from_changesets`, which panics on empty input. Restore the terminal BEFORE // printing: the message must land on the normal screen, not vanish with the alternate // one. A tty-less launch has no terminal to restore — the message prints exactly as - // before CS5. + // before the launch splash and early terminal takeover. if views.is_empty() || (views.len() == 1 && views[0].file_count() == 0) { if let Ok(tui) = tui.as_mut() { tui.restore().into_diagnostic()?; @@ -209,7 +216,8 @@ fn main() -> Result<()> { // `App` owns its own `Repository` handle (see `app.rs`'s doc comment) — moved in here // after acquisition is done borrowing it. `App::from_changesets` opens on whichever - // changeset the lib marked `current` (locked decision #6). + // changeset the lib marked `current` (locked decision: open on whichever changeset the + // lib marks current). let mut app = seat_app( repo, views, @@ -220,7 +228,8 @@ fn main() -> Result<()> { ); // A carried acquire failure surfaces HERE — the same logical point (running the TUI) it - // surfaced at before CS5 moved the terminal takeover ahead of the diff phase. + // surfaced at before the launch splash and early terminal takeover moved the terminal + // takeover ahead of the diff phase. tui.into_diagnostic()? .run(&mut app, keymap, theme, repo_path, &palette_ctx) .into_diagnostic()?; @@ -253,9 +262,10 @@ fn main() -> Result<()> { } /// The app-seating tail both `changesets.len()` arms of `main` share byte-identically (F5): -/// build `App` from `views`, wire the review source, defer file loads (CS4), apply CS7's -/// view-config settings, open the current file, and surface any keymap/view-config/theme-override -/// warnings as a startup notice. `open_current` is a no-op on an empty file list — safe for the +/// build `App` from `views`, wire the review source, defer file loads (idle-deferred file +/// loads), apply the view-config settings, open the current file, and surface any +/// keymap/view-config/theme-override warnings as a startup notice. `open_current` is a no-op +/// on an empty file list — safe for the /// streamed arm's `Pending` slots (no files yet), which `Tui::run_streamed`'s `ChangesetReady` /// handling re-runs it for once the active changeset's diff actually lands. fn seat_app( @@ -270,14 +280,15 @@ fn seat_app( if let Some(source) = source { app.set_review_source(source); } - // CS4: defer file loads to the event loop's input-idle window rather than blocking here (or - // on any later selection change) — `app.open_current()` below marks the initial open pending + // Idle-deferred file loads: defer file loads to the event loop's input-idle window rather than + // blocking here (or on any later selection change) — `app.open_current()` below marks the + // initial open pending // instead of loading eagerly; see `tui::run`'s doc comment for the resulting startup // contract. app.set_defer_loads(true); - // Apply CS7's view-config settings BEFORE `open_current`: `App::apply_view_config`'s setters - // only set the raw layout/zoom/mode/width fields, and `open_current` is what derives + // Apply the view-config settings BEFORE `open_current`: `App::apply_view_config`'s setters + // only set the raw layout/mode/width fields, and `open_current` is what derives // `cursor`/`scroll` fresh from whichever settings just landed (see each setter's doc // comment). let view_config_warnings = app.apply_view_config(view_config); @@ -285,34 +296,22 @@ fn seat_app( // A misconfigured keybinding, view-config setting, or theme override is non-fatal: show the // collected warnings as a startup notice (cleared on the first keypress, like any notice) and - // run with the defaults for those keys/settings/colors — `plumb_zoom_hint_and_warnings` also - // re-plumbs the resolved CycleZoom binding into the "cycle zoom" refusal hint, see its doc - // comment. + // run with the defaults for those keys/settings/colors. let mut extra_warnings = view_config_warnings; extra_warnings.extend(theme_override_warnings.iter().cloned()); - plumb_zoom_hint_and_warnings(&mut app, keymap, extra_warnings); + surface_warnings(&mut app, keymap, extra_warnings); app } -/// The zoom-hint plumbing + warning-aggregation tail `seat_app` (above) and `tui::event_loop`'s -/// `reload-config` handling both need — the same structural core as `config::resolve_runtime`, -/// so a change to how warnings surface or how the zoom hint is plumbed needs only one edit. Sets -/// the "cycle zoom" refusal hint from `keymap`'s resolved `CycleZoom` binding (App has no keymap -/// field of its own — see `App::zoom_key_label`'s doc comment; leaves the previous label in -/// place if the command has no bound key), then merges `keymap.warnings()` with `extra_warnings` +/// The warning-aggregation tail `seat_app` (above) and `tui::event_loop`'s `reload-config` +/// handling both need — the same structural core as `config::resolve_runtime`, so a change to +/// how warnings surface needs only one edit. Merges `keymap.warnings()` with `extra_warnings` /// (view-config/theme-override warnings, already collected by the caller) and shows them as a /// notice, cleared on the first keypress like any notice. Returns whether any warnings were /// shown, so a reload can layer its own "config reloaded" success notice only when nothing /// needed reporting. -fn plumb_zoom_hint_and_warnings( - app: &mut App, - keymap: &Keymap, - extra_warnings: Vec, -) -> bool { - if let Some(label) = keymap::primary_key(keymap, Command::CycleZoom) { - app.set_zoom_key_label(label); - } +fn surface_warnings(app: &mut App, keymap: &Keymap, extra_warnings: Vec) -> bool { let mut warnings = keymap.warnings().to_vec(); warnings.extend(extra_warnings); let had_warnings = !warnings.is_empty(); diff --git a/git-workon-review/src/model.rs b/git-workon-review/src/model.rs index 06f6560e..fe4d72f5 100644 --- a/git-workon-review/src/model.rs +++ b/git-workon-review/src/model.rs @@ -1,8 +1,9 @@ //! The diff model: [`DiffModel`]/[`FileChange`]/[`Hunk`]/[`HunkLine`] built directly from //! git2 [`git2::Diff`]/[`git2::Patch`] structures. //! -//! Per the M2 design decision, this is NOT a unified-diff-text parser: it walks git2's own -//! line callbacks (content bytes + origin chars, including the EOFNL origins `=`/`>`/`<`) so +//! Per the diff-model-and-patch-synthesis design decision, this is NOT a unified-diff-text +//! parser: it walks git2's own line callbacks (content bytes + origin chars, including the +//! EOFNL origins `=`/`>`/`<`) so //! the model can byte-exactly re-render the patches it read ([`Hunk::to_diff_bytes`]). //! //! ## EOFNL characterization (see `tests/diff_model.rs`) @@ -102,10 +103,11 @@ pub enum FileStatus { } impl FileStatus { - /// The single-character letter the outline's file rows render for this status (CS5): - /// `M`/`A`/`D`/`R`/`C`/`?`/`U`, mirroring `git status --short`'s XY letters where they exist - /// (`?` for untracked, `U` for unmerged/conflicted — git's own convention, not this crate's - /// invention). No mapping like this existed elsewhere in the crate before CS5 (checked the + /// The single-character letter the outline's file rows render for this status (file-status + /// letters and opt-in nerd icons): `M`/`A`/`D`/`R`/`C`/`?`/`U`, mirroring `git status + /// --short`'s XY letters where they exist (`?` for untracked, `U` for unmerged/conflicted — + /// git's own convention, not this crate's invention). No mapping like this existed + /// elsewhere in the crate before this work (checked the /// winbar/header, which only special-cases `Renamed`/`Copied` for the `old -> new` label, /// never prints a letter) — this is the canonical one going forward. pub fn letter(self) -> char { @@ -131,7 +133,7 @@ impl From for FileStatus { git2::Delta::Untracked => FileStatus::Untracked, git2::Delta::Conflicted => FileStatus::Unmerged, // Modified, Unmodified, Ignored, Typechange, Unreadable: none of these are - // distinct routing targets in the M2 model; fall back to Modified, the ordinary + // distinct routing targets in the diff model; fall back to Modified, the ordinary // hunk-diffable case. _ => FileStatus::Modified, } diff --git a/git-workon-review/src/ops.rs b/git-workon-review/src/ops.rs index fbb536ab..f690a6be 100644 --- a/git-workon-review/src/ops.rs +++ b/git-workon-review/src/ops.rs @@ -1,10 +1,11 @@ -//! Routing: the ONE place (per the M2 design decision) that decides, for a given -//! [`FileChange`], whether a staging verb goes through the patch-synthesis-and-apply path -//! (`synthesis.rs`/`apply.rs`) or the whole-file path (`file_ops.rs`). The TUI (M4) calls only +//! Routing: the ONE place (per the diff-model-and-patch-synthesis design decision) that +//! decides, for a given [`FileChange`], whether a staging verb goes through the +//! patch-synthesis-and-apply path (`synthesis.rs`/`apply.rs`) or the whole-file path +//! (`file_ops.rs`). The TUI (staging verbs) calls only //! [`apply_hunk`]/[`apply_lines`]/[`apply_line_selections`]/[`apply_file`] — it never picks a //! path itself. //! -//! ## The routing table (trap 3) +//! ## The routing table (whole-file-ops fallback) //! //! - [`FileStatus::Modified`]/[`FileStatus::Renamed`]/[`FileStatus::Copied`], non-binary: a //! hunk patch can express both a preimage and a postimage, so `apply_hunk`/`apply_lines` @@ -38,9 +39,9 @@ use crate::synthesis::{partial_hunk_patch, whole_hunk_patch, LineSelection, Patc /// doc comments above. /// /// Also the **line-op eligibility predicate** the TUI pre-validates against before offering -/// line selection: `apply_lines` REFUSES a non-hunk-patchable file (trap 3, no silent widening), -/// so the TUI checks this first and shows a "use the whole-file op" notice instead of enqueuing a -/// doomed line op. +/// line selection: `apply_lines` REFUSES a non-hunk-patchable file (whole-file-ops fallback, no +/// silent widening), so the TUI checks this first and shows a "use the whole-file op" notice +/// instead of enqueuing a doomed line op. pub fn is_hunk_patchable(file: &FileChange) -> bool { !file.is_binary && matches!( @@ -87,15 +88,15 @@ pub fn apply_hunk( /// Apply `verb` to a line-precise selection of `file`'s hunk at `hunk_idx`. /// /// Unlike `apply_hunk`, this never falls back to a file-level op: line selection on a status a -/// hunk patch can't express (or a binary file) is a REFUSAL (trap 3), not a silent widening to -/// "the whole file." `partial_hunk_patch` already carries that refusal +/// hunk patch can't express (or a binary file) is a REFUSAL (whole-file-ops fallback), not a +/// silent widening to "the whole file." `partial_hunk_patch` already carries that refusal /// ([`crate::error::SynthesisError::LineSelectionUnsupported`] / /// [`crate::error::SynthesisError::BinaryFile`]), so calling it unconditionally and propagating /// its `Result` is both the simplest routing and the correct one. /// /// Single-hunk only — see [`apply_line_selections`] for a selection spanning multiple hunks, -/// which must NOT be applied as N separate calls to this function (trap 7, see that function's -/// docs). +/// which must NOT be applied as N separate calls to this function (stale-metadata head, see +/// that function's docs). pub fn apply_lines( repo: &Repository, applier: &dyn Applier, @@ -111,7 +112,7 @@ pub fn apply_lines( } /// Apply `verb` to a line-precise selection spanning POSSIBLY MULTIPLE hunks of `file`, as ONE -/// combined patch (trap 7). +/// combined patch (stale-metadata head). /// /// Each `(hunk_idx, LineSelection)` synthesizes its own single-hunk [`PatchText`] via /// [`partial_hunk_patch`] (same refusals as [`apply_lines`]: propagated from the FIRST hunk that diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index bd06bbd9..dc744763 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -1,11 +1,14 @@ //! The outline side pane's pure item model: given a snapshot of every reviewed changeset (label, //! current/needs-restack flags, and per-file staged-ness), build the flat row list the pane //! renders and the outline cursor indexes — no [`crate::app::App`]/[`crate::app::ChangesetView`] -//! dependency, mirroring how [`crate::attribute`] stays a pure module consumed by `app`/`render`. +//! dependency, same posture as [`crate::align`]'s pure row-alignment module consumed by +//! `app`/`render`. //! -//! CS3 shipped two of the four modes ([`OutlineMode::Flat`]/[`OutlineMode::Stack`]); CS4 added +//! The outline side pane (flat and stack modes) shipped two of the four modes +//! ([`OutlineMode::Flat`]/[`OutlineMode::Stack`]); the outline's path-trie tree modes added //! the two path-trie modes ([`OutlineMode::Tree`]/[`OutlineMode::StackTree`]) via the private -//! [`TrieNode`] builder below. CS5 adds each file row's [`crate::model::FileStatus`] (the `M`/ +//! [`TrieNode`] builder below. File-status letters and opt-in nerd icons adds each file row's +//! [`crate::model::FileStatus`] (the `M`/ //! `A`/`D`/... change-status letter — see [`OutlineFile::change`]/[`OutlineItem::File::change`]'s //! doc comments for why that's a wholly separate field from [`StagedStatus`], which tracks //! index/worktree staged-ness, not the underlying change kind). Pulling in @@ -13,9 +16,10 @@ //! a pure data module (no `App`/`ChangesetView` dependency), so importing its plain enum doesn't //! reintroduce the `App` coupling this module was factored out to avoid. //! -//! CS5 (`outline-fold`) also adds a second stage layered on top of [`build_items`]: collapse/ -//! expand. [`build_items`] itself stays wholly unaware of fold state (its extensive mode/dedup/ -//! guide tests below are untouched by CS5) — [`apply_fold`] takes its output and a per-row +//! Outline collapse/expand (fold) (`outline-fold`) also adds a second stage layered on top of +//! [`build_items`]: collapse/expand. [`build_items`] itself stays wholly unaware of fold state (its +//! extensive mode/dedup/guide tests below are untouched by it) — [`apply_fold`] takes its output +//! and a per-row //! collapsed predicate and returns the filtered row list plus the two extra pieces of data render/ //! cursor logic needs (a collapsed row's hidden-file count, and a full-list -> filtered-list index //! map for re-finding a fold-hidden target). [`fold_outline`] is the two steps composed — @@ -36,8 +40,8 @@ pub enum OutlineMode { /// Every changed path across the whole stack, once each, no changeset headers. Flat, /// A changeset header row per changeset, followed by that changeset's file rows — the - /// default (locked choice for CS3: this is the mode that actually shows the stack - /// structure M5 exists to surface). + /// default (locked choice for the outline side pane (flat and stack modes): this is the mode + /// that actually shows the stack structure the stack-and-outline work exists to surface). #[default] Stack, /// [`Self::Flat`]'s de-duped path set, rendered as a directory trie (dir rows + file leaves) @@ -49,7 +53,8 @@ pub enum OutlineMode { } impl OutlineMode { - /// `i`'s cycle order: `Stack -> StackTree -> Flat -> Tree -> Stack` (CS4) — the default + /// `i`'s cycle order: `Stack -> StackTree -> Flat -> Tree -> Stack` (`outline-mode-cycle`) — + /// the default /// [`Self::Stack`] leads, its trie sibling [`Self::StackTree`] follows immediately, then the /// non-grouped pair [`Self::Flat`]/[`Self::Tree`] closes the loop. pub fn cycle(self) -> Self { @@ -61,7 +66,7 @@ impl OutlineMode { } } - /// The kebab-cased display name (CS4, `outline-mode-cycle`) — used by the footer's `i + /// The kebab-cased display name (`outline-mode-cycle`) — used by the footer's `i /// →` hint and mirrors `OUTLINE_MODE_OPTIONS`'s config strings (`app.rs`), so the /// two never drift apart. pub fn label(self) -> &'static str { @@ -75,16 +80,18 @@ impl OutlineMode { } /// Which end of the stack the outline's stack-shaped modes ([`OutlineMode::Stack`]/ -/// [`OutlineMode::StackTree`]) display first — CS3 dogfooding feedback #2. Purely a display +/// [`OutlineMode::StackTree`]) display first — outline-side-pane (flat and stack modes) +/// dogfooding feedback #2. Purely a display /// order: [`OutlineItem`]'s `cs_idx`/`file_idx` always stay TRUE indices into `App::changesets` /// regardless of which way the rows are painted (see [`build_items`]'s doc comment). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum OutlineOrder { - /// The most recently created (head) changeset's header renders first — the CS3 default. + /// The most recently created (head) changeset's header renders first — the outline-side-pane + /// (flat and stack modes) default. #[default] HeadFirst, /// The stack's base changeset renders first, matching `App::changesets`' own base -> head - /// storage order (today's pre-CS3 behavior). + /// storage order (today's pre-outline-side-pane behavior). BaseFirst, } @@ -104,7 +111,7 @@ fn scan_order( } /// A file's staged-ness for the outline's status column — the data model `render.rs` derives its -/// git-porcelain-style X/Y two-column status matrix from (CS3, `outline-status-xy`). Only +/// git-porcelain-style X/Y two-column status matrix from (`outline-status-xy`). Only /// meaningful for the uncommitted changeset's files; a committed changeset's files always /// resolve to `None` because their `unstaged_idx`/`staged_idx` maps are always-empty (see /// `DiffState::from_committed`) — the same "derive, don't special-case" collapse @@ -115,7 +122,7 @@ fn scan_order( #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum StagedStatus { /// No staged/unstaged sub-diff info for this file (a committed changeset's file, or an - /// uncommitted file that — impossibly — has a combined change but neither sub-change). + /// uncommitted file that — impossibly — has a whole-role change but neither sub-change). #[default] None, /// The file has an unstaged (index ↔ worktree) change but no staged one. @@ -150,7 +157,8 @@ pub struct OutlineFile { /// underlying change is `Deleted` (that one) — they answer different questions ("is it /// staged" vs. "what kind of change is it") and must stay two distinct fields. pub status: StagedStatus, - /// CS5: the underlying change kind (Modified/Added/Deleted/...), lifted from the owning + /// File-status letters and opt-in nerd icons: the underlying change kind + /// (Modified/Added/Deleted/...), lifted from the owning /// [`crate::model::FileChange::status`] — drives the outline's `M`/`A`/`D`/`R`/`C`/`?`/`U` /// letter (`render::build_outline_line`), independent of [`Self::status`] above. pub change: FileStatus, @@ -182,11 +190,13 @@ pub struct OutlineChangeset { /// index into THAT changeset's file list — together they're exactly what /// `App::switch_changeset`/`App::goto_changeset` need to jump the diff there. /// -/// `guides` (on [`Self::Dir`]/[`Self::File`]) is the tree-guide vector CS4 adds: one bool per +/// `guides` (on [`Self::Dir`]/[`Self::File`]) is the tree-guide vector the outline's +/// path-trie tree modes adds: one bool per /// nesting level from the shallowest ancestor down to the row itself, `true` meaning "this /// level is its parent's last child". Rendering uses every-element-but-the-last to decide /// whether to draw a continuing `│` or blank space at that column, and the last element to draw -/// `╰─`/`├─` for the row's own connector (CS4 rounds the last-child corner). [`OutlineMode::Flat`]/[`OutlineMode::Stack`] rows carry +/// `╰─`/`├─` for the row's own connector (the outline's path-trie tree modes rounds the +/// last-child corner). [`OutlineMode::Flat`]/[`OutlineMode::Stack`] rows carry /// an EMPTY `guides` — that's the signal to `render::build_outline_line` to fall back to the /// flat two-space indent instead of drawing tree connectors; a non-empty `guides` of length 1 /// means "top-level tree row" (depth 0), so emptiness and depth-0 are deliberately distinguishable. @@ -195,7 +205,7 @@ pub enum OutlineItem { /// A changeset header — emitted in [`OutlineMode::Stack`]/[`OutlineMode::StackTree`]. Header { cs_idx: usize, - /// Changeset count (CS1, `outline-header-polish`) — paired with `cs_idx` at render time + /// Changeset count (`outline-header-polish`) — paired with `cs_idx` at render time /// to draw the `[i/n]` counter (`i` = `cs_idx + 1`, base=1). Always `changesets.len()` at /// build time, so it's the same for every `Header` row a given `build_items` call emits. n: usize, @@ -210,25 +220,27 @@ pub enum OutlineItem { /// A directory row — only emitted in [`OutlineMode::Tree`]/[`OutlineMode::StackTree`]. Not a /// jump target: it carries no `file_idx`, so `App::outline_move_by` no-ops on it (same as /// [`Self::Header`]); `App::outline_confirm` toggles this row's fold state instead of jumping - /// (CS5, `outline-fold`) and deliberately does NOT return focus to the diff — see that + /// (`outline-fold`) and deliberately does NOT return focus to the diff — see that /// method's doc comment. Fold state itself lives on `App` (per-[`OutlineMode`] sets keyed by /// [`FoldKey`]), not here — this row stays a plain data snapshot either way. Dir { name: String, /// The FULL path from the trie root (e.g. `"src/cmd"`), unlike `name` which is just the - /// leaf segment — CS4's summary panel needs the whole path to filter files under this + /// leaf segment — the summary panel needs the whole path to filter files under this /// directory (see `crate::summary::dir_summary`). path: String, /// `Some(cs_idx)` when this row's trie is per-changeset ([`OutlineMode::StackTree`] — /// the same true index its owning [`Self::Header`] carries); `None` in the cross-stack /// [`OutlineMode::Tree`], whose single trie spans every changeset (so a dir row there has - /// no single owning changeset to scope a summary to — CS4's `App::summary_for` instead + /// no single owning changeset to scope a summary to — the summary panel's + /// `App::summary_for` instead /// aggregates over [`latest_by_path`]'s de-duped set for that case). cs_idx: Option, guides: Vec, }, /// A file row — the target of every outline->diff jump. `path` is the FULL path in - /// [`OutlineMode::Flat`]/[`OutlineMode::Stack`] (unchanged from CS3), but is just the leaf + /// [`OutlineMode::Flat`]/[`OutlineMode::Stack`] (unchanged from the outline side pane (flat and + /// stack modes)), but is just the leaf /// segment in [`OutlineMode::Tree`]/[`OutlineMode::StackTree`] — the ancestor directory rows /// already carry the rest of the path, so re-printing it on every leaf would be redundant. File { @@ -236,7 +248,8 @@ pub enum OutlineItem { file_idx: usize, path: String, status: StagedStatus, - /// CS5: the change kind (Modified/Added/Deleted/...) — see [`OutlineFile::change`]'s doc + /// File-status letters and opt-in nerd icons: the change kind (Modified/Added/Deleted/...) + /// — see [`OutlineFile::change`]'s doc /// comment on why this is distinct from `status` above. change: FileStatus, guides: Vec, @@ -263,7 +276,8 @@ impl OutlineItem { /// ROW SEQUENCE the outline paints flips. [`build_tree`]'s de-dupe is order-independent (see its /// own doc comment), so `order` is accepted but unused there. /// -/// `pub(crate)` (CS5): this is the "unfiltered build" [`fold_outline`]'s doc comment refers to — +/// `pub(crate)` (`outline-fold`): this is the "unfiltered build" [`fold_outline`]'s doc comment +/// refers to — /// every outside-the-module consumer (i.e. `App`) goes through `fold_outline`/`apply_fold` /// instead, so a fold is never accidentally bypassed by calling this directly. pub(crate) fn build_items( @@ -274,7 +288,8 @@ pub(crate) fn build_items( build_items_inner(changesets, mode, order, None) } -/// [`build_items`] with CS2's per-row inclusion gate (`filter`) layered on — the REVISED +/// [`build_items`] with the outline fuzzy filter's per-row inclusion gate (`filter`) layered on — +/// the REVISED /// 2026-07-24 "rebuild from the surviving file set" entry point [`fold_outline_filtered`] calls. /// Deliberately walks the SAME, full, unpruned `changesets` slice `build_items` does (see /// [`is_included`]'s doc comment) — every `cs_idx`/`file_idx` this emits is therefore still @@ -304,7 +319,7 @@ fn build_items_inner( } } -// ── Fold (collapse/expand), CS5 `outline-fold` ────────────────────────────────── +// ── Fold (collapse/expand), `outline-fold` ───────────────────────────────────── /// A foldable outline row's identity — the key `App`'s per-[`OutlineMode`] fold sets store. /// [`OutlineItem::Header`] is keyed by its changeset's label PLUS its `cs_idx`; [`OutlineItem::Dir`] @@ -346,7 +361,8 @@ impl FoldKey { } } -/// The outline's row list after CS5's fold filtering is layered on top of [`build_items`]'s raw +/// The outline's row list after `outline-fold`'s fold filtering is layered on top of +/// [`build_items`]'s raw /// build — see [`apply_fold`]/[`fold_outline`]'s doc comments for how it's derived, and /// `App::outline_items`'s doc comment for why this is the SINGLE choke point every cursor/ /// staging/render consumer reads through. @@ -354,15 +370,17 @@ impl FoldKey { pub(crate) struct FoldedOutline { /// The visible rows, in order — a subsequence of [`build_items`]'s full (unfiltered) output. pub items: Vec, - /// Parallel to `items`: the count of hidden FILE rows (not dirs — CS5's locked "N = hidden - /// FILE rows only" rule) under a collapsed Header/Dir row. `0` for every other row, including + /// Parallel to `items`: the count of hidden FILE rows (not dirs — + /// `outline-fold`'s locked "N = hidden FILE rows only" rule) under a collapsed Header/Dir + /// row. `0` for every other row, including /// an EXPANDED Header/Dir — render reads `0` as "no marker", so an expanded row never draws /// the trailing ` ▸ N` chevron. pub hidden_counts: Vec, /// Parallel to the FULL (unfiltered) [`build_items`] output, NOT to `items`: for original row /// `i`, the index into `items`/`hidden_counts` a cursor targeting that row should land on — /// its own filtered position if it survived filtering, or its nearest VISIBLE ancestor's if a - /// fold hides it (CS5's "lands on the collapsed ancestor without auto-expanding" rule). Used + /// fold hides it (`outline-fold`'s "lands on the collapsed ancestor without auto-expanding" + /// rule). Used /// by `App::sync_outline_to_current` to re-target a diff-initiated jump onto a folded row's /// row instead of leaving the outline cursor on an arbitrary clamp. pub visible_index: Vec, @@ -481,7 +499,7 @@ pub(crate) fn fold_outline( apply_fold(&items, is_folded) } -// ── Fuzzy filter (CS2 `outline-filter`, REVISED 2026-07-24: filter-then-rebuild) ──── +// ── Fuzzy filter (`outline-filter`, REVISED 2026-07-24: filter-then-rebuild) ─────── /// One row's fuzzy-match result against the SOURCE text it was scored on (a changeset's title, or /// a file's FULL repo-relative path — never a dir segment or a tree leaf; REVISED 2026-07-24 drops @@ -568,7 +586,8 @@ fn score_changesets(changesets: &[OutlineChangeset], query: &str) -> QueryMatche } /// Whether `(cs_idx, file_idx)` survives filtering: unconditionally `true` when `filter` is -/// `None` (the ordinary, unfiltered build every pre-CS2 test exercises), else `true` when either +/// `None` (the ordinary, unfiltered build every pre-outline-filter test exercises), else `true` +/// when either /// the file's OWN path matched, or its changeset's TITLE matched (a title match "keeps the WHOLE /// changeset, all files" — see [`score_changesets`]'s doc comment). fn is_included(filter: Option<&QueryMatches>, cs_idx: usize, file_idx: usize) -> bool { @@ -818,7 +837,7 @@ fn build_flat( /// [`build_flat`]'s last-write-wins rule), independent of iteration/insertion order — the trie /// builders below re-sort by path segment anyway, so no stable-order bookkeeping is needed here. /// -/// `pub(crate)`: CS4's `App::summary_for` reuses this directly to aggregate a +/// `pub(crate)`: the summary panel's `App::summary_for` reuses this directly to aggregate a /// [`OutlineMode::Tree`] directory's files (`cs_idx: None` on that mode's [`OutlineItem::Dir`] /// rows) over the same last-write-wins de-duped set the Tree outline itself displays, rather than /// re-deriving the dedup logic in `app.rs`. @@ -842,7 +861,8 @@ pub(crate) fn latest_by_path(changesets: &[OutlineChangeset]) -> HashMap base), but [`latest_by_path`]'s "closest-to-head wins" TARGET /// resolution never changes — a path touched by two changesets must resolve to the head-most /// one under BOTH orders. @@ -1465,7 +1488,8 @@ mod tests { ); } - /// CS3: [`OutlineOrder::HeadFirst`] (the new default) shows the LAST changeset's ([`cs-c`], + /// The outline side pane (flat and stack modes): [`OutlineOrder::HeadFirst`] (the new + /// default) shows the LAST changeset's ([`cs-c`], /// index 2 — the true, base-> head `App::changesets` index) header FIRST, while its `cs_idx` /// still equals its true index into `changesets` (2), never a display-order index (0). #[test] @@ -1504,7 +1528,8 @@ mod tests { ); } - /// CS3: [`OutlineOrder::BaseFirst`] restores the pre-CS3 base -> head header order. + /// The outline side pane (flat and stack modes): [`OutlineOrder::BaseFirst`] restores the + /// pre-outline-side-pane base -> head header order. #[test] fn stack_mode_base_first_restores_base_to_head_header_order() { let changesets = vec![ @@ -1558,7 +1583,7 @@ mod tests { ); } - // ── Fold (collapse/expand), CS5 `outline-fold` ────────────────────────────── + // ── Fold (collapse/expand), `outline-fold` ───────────────────────────────── #[test] fn apply_fold_with_nothing_folded_leaves_every_row_visible_with_zero_markers() { @@ -1800,7 +1825,7 @@ mod tests { assert_eq!(folded.hidden_counts, vec![2]); } - // ── Fuzzy filter (CS2 `outline-filter`, REVISED 2026-07-24: filter-then-rebuild) ──── + // ── Fuzzy filter (`outline-filter`, REVISED 2026-07-24: filter-then-rebuild) ─────── /// `fold_outline_filtered` with an always-visible fold (no key folded) — the shape most of /// these tests want; a couple below pass their own predicate to check fold interaction. diff --git a/git-workon-review/src/prompt.rs b/git-workon-review/src/prompt.rs index e6034bd3..6d2d7ae8 100644 --- a/git-workon-review/src/prompt.rs +++ b/git-workon-review/src/prompt.rs @@ -1,9 +1,10 @@ //! A one-row text-input primitive: [`PromptState`] holds a buffer plus a byte-offset cursor and //! exposes pure edit operations (never touches [`crate::app::App`] or terminal I/O — the //! keymap/cascade wiring that turns key events into these calls, and the pane it renders inside, -//! are a later changeset's job). M11's outline filter (`/` in the outline pane) and diff search -//! (`/` in the diff pane) both need "one editable line with a blinking-cursor feel"; rather than -//! grow that logic twice, this module is that shared line editor, built once and unused until +//! are a later changeset's job). In-diff navigation's outline fuzzy filter (`/` in the +//! outline pane) and in-diff search (`/` in the diff pane) both need "one editable line with a +//! blinking-cursor feel"; rather than grow that logic twice, this module is that shared line +//! editor, built once and unused until //! the next two changesets wire it up. //! //! Emacs/readline-flavored bindings were chosen over vim-insert-mode ones because the prototype's @@ -53,14 +54,14 @@ impl PromptState { } /// `true` when nothing has been typed — the caller-facing "is there a query at all" check - /// (M11's outline filter/diff search both fall back to their unfiltered/inactive behavior on - /// an empty buffer). + /// (the outline fuzzy filter/in-diff search both fall back to their unfiltered/inactive + /// behavior on an empty buffer). pub fn is_empty(&self) -> bool { self.buffer.is_empty() } - /// Reset to a fresh, empty prompt — `Ctrl-c`'s "clear and defocus" behavior (M11's outline - /// filter) is one call to this plus a focus-flag flip the caller owns. + /// Reset to a fresh, empty prompt — `Ctrl-c`'s "clear and defocus" behavior (the outline + /// fuzzy filter) is one call to this plus a focus-flag flip the caller owns. pub fn clear(&mut self) { self.buffer.clear(); self.cursor = 0; diff --git a/git-workon-review/src/queue.rs b/git-workon-review/src/queue.rs index 282997cd..d3409d4d 100644 --- a/git-workon-review/src/queue.rs +++ b/git-workon-review/src/queue.rs @@ -1,6 +1,7 @@ -//! FIFO staging queue (trap 4): callers enqueue [`StagingOp`]s, [`StagingQueue::pump`] runs the -//! head op synchronously against the live index. Runtime-agnostic per the M2 design decision — -//! no tokio, no owned thread; the caller (M4's TUI event loop) decides when to pump. +//! FIFO staging queue (live-index staging queue): callers enqueue [`StagingOp`]s, +//! [`StagingQueue::pump`] runs the head op synchronously against the live index. +//! Runtime-agnostic per the diff-model-and-patch-synthesis design decision — no tokio, no owned +//! thread; the caller (the staging-verbs TUI event loop) decides when to pump. //! //! ## The stale-snapshot trap //! @@ -69,9 +70,10 @@ pub trait StagingOp: Send { } /// Lets an already-boxed trait object be re-enqueued through [`StagingQueue::enqueue`] (which -/// takes `impl StagingOp + 'static` and boxes internally) without unboxing first — CS7's -/// `App::run_ops` collects a `Vec>` of heterogeneous per-file ops (one -/// [`crate::stage_op::FileStagingOp`] per outline target) and enqueues them one at a time. +/// takes `impl StagingOp + 'static` and boxes internally) without unboxing first — the outline +/// staging verbs' `App::run_ops` collects a `Vec>` of heterogeneous per-file +/// ops (one [`crate::stage_op::FileStagingOp`] per outline target) and enqueues them one at a +/// time. impl StagingOp for Box { fn run(&mut self, ctx: &OpContext<'_>) -> Result<(), ApplyError> { (**self).run(ctx) @@ -91,8 +93,9 @@ pub enum OpOutcome { /// otherwise read as a false positive candidate once combined with the rest of the struct). type SleepFn = Box; -/// Runtime-agnostic FIFO queue of staging operations (trap 4). Only the head op ever runs; -/// [`StagingQueue::pump`] runs it synchronously to completion (including its one retry, if +/// Runtime-agnostic FIFO queue of staging operations (live-index staging queue). Only the head op +/// ever runs; [`StagingQueue::pump`] runs it synchronously to completion (including its one retry, +/// if /// index-lock contention is hit) before removing it. pub struct StagingQueue { queue: VecDeque<(OpId, Box)>, @@ -315,8 +318,8 @@ mod tests { } /// Toggles staged/unstaged state of `path` by resolving direction from the LIVE index - /// inside `run` — proves ops must not cache the direction at enqueue time (trap 4's - /// stale-snapshot bug). + /// inside `run` — proves ops must not cache the direction at enqueue time (the live-index + /// staging queue's stale-snapshot bug). struct ToggleOp { path: &'static str, } diff --git a/git-workon-review/src/refresh.rs b/git-workon-review/src/refresh.rs index 52e645ea..134ae306 100644 --- a/git-workon-review/src/refresh.rs +++ b/git-workon-review/src/refresh.rs @@ -1,5 +1,6 @@ -//! Refresh generation/livelock coordination (trap 5): a pure state machine tracking which -//! re-diff is the latest one requested, so a slow refresh that finishes after a newer one has +//! Refresh generation/livelock coordination (refresh echo suppression): a pure state machine +//! tracking which re-diff is the latest one requested, so a slow refresh that finishes after a +//! newer one has //! already started doesn't clobber fresher results. //! //! ## Interlock with `queue.rs` @@ -7,9 +8,10 @@ //! [`RefreshCoordinator::note_index_event`] refuses to schedule a refresh while //! [`crate::queue::StagingQueue::len`] is nonzero (passed in as `staging_queue_len`) — a //! refresh only makes sense once the queue has drained, since an in-flight staging op is about -//! to change the index again anyway (trap 4/5 interlock). +//! to change the index again anyway (the live-index-staging-queue/refresh-echo-suppression +//! interlock). //! -//! ## M4 wiring intent (forward-looking; not built here) +//! ## Staging-verbs wiring intent (forward-looking; not built here) //! //! A filesystem watcher will call [`RefreshCoordinator::note_index_event`] whenever `.git/index` //! changes, using [`IndexSignature::read`] to build the signature. When it returns `true`, the @@ -29,9 +31,10 @@ pub struct IndexSignature { } impl IndexSignature { - /// Read the current signature of `/index`. M4 convenience for wiring a real - /// filesystem watcher; M2's tests use synthetic signatures (see `tests` below) since the - /// coordinator's logic never inspects the fields itself, only compares whole signatures. + /// Read the current signature of `/index`. A staging-verbs convenience for wiring a + /// real filesystem watcher; this module's own tests use synthetic signatures (see `tests` + /// below) since the coordinator's logic never inspects the fields itself, only compares + /// whole signatures. pub fn read(git_dir: &Path) -> io::Result { use std::os::unix::fs::MetadataExt; @@ -62,8 +65,9 @@ pub enum Completion { Superseded, } -/// Generation counter + last-seen index signature, implementing the trap-5 supersede/livelock -/// invariants. See the module docs for the `queue.rs` interlock and the M4 wiring intent. +/// Generation counter + last-seen index signature, implementing the refresh-echo-suppression +/// supersede/livelock invariants. See the module docs for the `queue.rs` interlock and the +/// staging-verbs wiring intent. pub struct RefreshCoordinator { next_gen: u64, latest_started: u64, @@ -97,8 +101,9 @@ impl RefreshCoordinator { /// If it recorded the signature here, a genuinely external change event would be "seen" and /// suppressed the moment it arrived, before any refresh even ran to observe it; worse, the /// signature that actually needs recording is the one a refresh completes with, at the - /// specific point trap 5 cares about (see `complete`), not the raw event that triggered the - /// refresh in the first place. Comparisons live here; recording lives in `complete`. + /// specific point refresh echo suppression cares about (see `complete`), not the raw event + /// that triggered the refresh in the first place. Comparisons live here; recording lives in + /// `complete`. pub fn note_index_event(&mut self, sig: IndexSignature, staging_queue_len: usize) -> bool { if staging_queue_len > 0 { return false; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 29df246e..c2cce0b5 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -18,7 +18,6 @@ use crate::app::{ App, DiffTextMode, EffectiveZoom, FileView, Layout as AppLayout, Notice, Region, Role, Severity, Summary, }; -use crate::attribute::Attribution; use crate::config::View; use crate::highlight::FgSpan; use crate::icons::IconMode; @@ -35,11 +34,12 @@ use crate::wordiff::Span as WordSpan; // default/dim/gutter chrome foreground ALSO now come from the palette (`theme.background`/ // `theme.foreground`/`theme.dim`/`theme.gutter`), as does the semantic chrome that used to be // const here — error/warn/current-marker are now `theme.error_fg`/`theme.warn_fg`/ -// `theme.current_fg` (CS2, revising ADR-035's hybrid boundary) — see the theme module's revised +// `theme.current_fg` (promoting semantic foregrounds to palette knobs, revising ADR-035's hybrid +// boundary) — see the theme module's revised // hybrid-boundary doc comment. A curated theme now fully controls the look; nothing in this // module hardcodes a semantic color anymore. -// CS3's nerd-mode status/header/summary glyphs (gated on `IconMode::Nerd`; the plain unicode +// The nerd-mode status/header/summary glyphs (gated on `IconMode::Nerd`; the plain unicode // defaults below stay byte-identical when `icons = none` — see icons.rs's module doc for why no // auto-detection ever picks Nerd for the user). Picked from the classic BMP nerd-font sets // (`fa`/`oct`) rather than devicons' broader (partly supplementary-plane) table, for wider @@ -63,7 +63,7 @@ const NERD_DIFF_REMOVED: char = '\u{f458}'; // nf-oct-diff-removed /// The current-changeset marker for the active icon strategy. These four one-switch helpers are /// the single source of each semantic marker's glyph pair — the outline's Header arm, the summary -/// panel, and the diff/outline pane headers (CS1, `pane-headers`) deliberately draw the SAME +/// panel, and the diff/outline pane headers (`pane-headers`) deliberately draw the SAME /// markers, so the selection lives in one place instead of a hand-synced `match` per call site. fn current_marker(icons: IconMode) -> char { match icons { @@ -140,15 +140,16 @@ fn diffstat_spans( /// The shared changeset-title span run — `[current-marker] [branch-icon] ([i/n] )label /// [warn-marker]` — drawn by both `build_outline_line`'s Header arm and -/// [`changeset_summary_lines`]. **The two call sites no longer render identically** (CS1, -/// `outline-header-polish`): `counter` is `Some((cs_idx + 1, n))` for the outline's Header row +/// [`changeset_summary_lines`]. **The two call sites no longer render identically** +/// (`outline-header-polish`): `counter` is `Some((cs_idx + 1, n))` for the outline's Header row /// only, and its presence ALSO switches the label from the plain [`Palette::foreground`] look to /// [`Palette::heading_fg`] + bold — the summary panel passes `None` and keeps the original -/// foreground-bold label with no counter, matching its pre-CS1 appearance exactly. Failed/loading +/// foreground-bold label with no counter, matching its pre-`outline-header-polish` appearance +/// exactly. Failed/loading /// markers are still NOT included: the two call sites place them differently (trailing spans on /// the header row vs. a line of their own in the summary). /// -/// `match_indices` (CS2, `outline-filter`) are CHAR indices into `label` itself — the outline's +/// `match_indices` (`outline-filter`) are CHAR indices into `label` itself — the outline's /// Header call site passes its row's fuzzy-match indices (empty when no filter is active, or the /// query didn't match this row); the summary panel's call site always passes `&[]` (it never /// filters). See [`highlight_filter_match`]'s doc comment for the highlight styling itself. @@ -198,13 +199,15 @@ fn changeset_title_spans( spans } -/// CS2 (`outline-filter`): render `text` char-by-char, layering [`Modifier::UNDERLINED`] on top of +/// The outline fuzzy filter (`outline-filter`): render `text` char-by-char, layering +/// [`Modifier::UNDERLINED`] on top of /// `base_style` for every char whose index is in `match_indices` (CHAR indices into `text`, from /// [`fuzzy_matcher::skim::SkimMatcherV2::fuzzy_indices`], remapped onto this row's own displayed /// text by [`crate::outline::fold_outline_filtered`]'s internals) — /// reuses the row's own EXISTING foreground/dim color rather than introducing a new theme field: -/// M11's later diff-search slice is what adds dedicated `tint_slot` match-highlight keys (per the -/// plan), so this filter — which CS2 owns start to finish — stays theme-neutral. Groups +/// In-diff navigation's later diff-search slice is what adds dedicated `tint_slot` match- +/// highlight keys (per the plan), so this filter — which the outline fuzzy filter owns start to +/// finish — stays theme-neutral. Groups /// consecutive matched/unmatched chars into as few spans as possible. `match_indices.is_empty()` /// (no filter active, or this row wasn't matched — e.g. the summary panel's call site, which never /// filters) is the common case and returns `text` as a single unstyled-beyond-`base_style` span, @@ -295,8 +298,8 @@ fn cursor_tint(theme: &Palette, focused: bool) -> Color { } /// Wash the cursor row with the theme's cursor tint — full [`Palette::cursor_bg`] when `focused` -/// is true (this pane holds focus), or the dimmer [`Palette::cursor_unfocused_bg`] otherwise (CS1, -/// `unfocused-cursor-wash`: the uniform model every pane's remembered cursor row now follows, +/// is true (this pane holds focus), or the dimmer [`Palette::cursor_unfocused_bg`] otherwise +/// (`unfocused-cursor-wash`: the uniform model every pane's remembered cursor row now follows, /// matching the outline's pre-existing focused/unfocused split). fn apply_cursor_row( line: Line<'static>, @@ -312,7 +315,7 @@ fn apply_selection_row(line: Line<'static>, width: u16, theme: &Palette) -> Line apply_row_tint(line, width, theme.selection_bg) } -/// Horizontal-scroll right-edge marker (decision #7): if `line` (as already blitted into `area` +/// Horizontal-scroll right-edge marker: if `line` (as already blitted into `area` /// by the caller's `set_line`) is wider than `area`'s content width, overwrite the pane's last /// cell with a dim `…` so a panned-right line still signals there's more to the right. Applied /// AFTER `set_line` (and after any cursor/selection wash, which paints its own background first) @@ -352,9 +355,11 @@ struct Segment { /// render-time resolution) — a segment with no covering syntax span falls back to /// [`Palette::foreground`]. /// -/// `fg_override_spans` (CS11, `content_spans`' `text_mode`) wins over the syntax-resolved color +/// `fg_override_spans` (the diff foreground/background split, `content_spans`' `text_mode`) wins +/// over the syntax-resolved color /// wherever it covers a byte range — `syntax` mode passes an empty slice, so this stays a no-op -/// and the segment's color/italic resolution is byte-identical to before CS11 (the changeset's +/// and the segment's color/italic resolution is byte-identical to before the diff +/// foreground/background split (the changeset's /// pixel-identity gate). Italic still resolves from the covering syntax capture regardless of /// which foreground wins — the two are orthogonal (a tinted comment stays italic). fn compose_segments( @@ -399,7 +404,8 @@ fn compose_segments( .find(|(s, e, _)| mid >= *s && mid < *e) .map(|(_, _, c)| *c); // One lookup, two consumers: italic and the syntax color must come from the SAME capture, - // and a tint override (CS11's `diff.text`) replaces only the color — italics stay + // and a tint override (the diff foreground/background split's `diff.text`) replaces only + // the color — italics stay // structural, so a tinted comment is still italic. let syntax_hit = fg_spans.and_then(|fgs| fgs.iter().find(|s| mid >= s.start && mid < s.end)); @@ -431,111 +437,75 @@ fn gutter_width(max_lineno: usize) -> usize { max_lineno.to_string().len().max(3) } -/// How a rendered pane resolves a changed cell's (line, edit) background pair — one per -/// [`Role`] (locked decision #7): the combined view is the only one that needs a per-cell lookup, -/// since it's the only role that fuses staged and unstaged content into one set of rows. +/// How a rendered pane resolves a changed cell's (line, edit) background pair — one per [`Role`]. +/// ADR-038, "Delete `attribute.rs` and its render integration": `Role::Whole` is reachable only for +/// a committed changeset (no +/// staged/unstaged split to attribute against) or a binary file (no cells at all), so it never +/// needs a per-cell staged-ness lookup any more — [`attribution_mode`] maps it straight to +/// `Plain`, the same as the unstaged pane. #[derive(Clone, Copy)] -enum AttributionMode<'a> { - /// Combined view: look up each cell's staged-ness in the given [`Attribution`], built fresh - /// for the current file this frame (see [`combined_attribution`]). - Attributed(&'a Attribution), - /// Unstaged zoom pane: every changed cell IS the not-yet-staged set — render bright, - /// unconditionally (today's plain colors). +enum AttributionMode { + /// The whole role, or the unstaged zoom pane: every changed cell IS the not-yet-staged set — + /// render bright, unconditionally (today's plain colors). Plain, /// Staged zoom pane (single-zoom or the split's bottom pane): every changed cell IS already /// staged — render dim, unconditionally. StagedUniform, } -/// Build the current file's [`Attribution`] when rendering the combined role, `None` for the -/// unstaged/staged roles (which don't need a per-cell lookup — see [`AttributionMode`]). Computed -/// fresh from the sub-models on every call rather than cached on `App`: cheap (O(hunk lines) on -/// one file) and always correct even if the index changes between frames (the M4 watcher's -/// concern, not this one's, but the cost of getting it wrong is a stale color). -fn combined_attribution(app: &App, idx: usize, role: Role) -> Option { - // A committed changeset's combined role is the whole `base..head` range, not a fusion of - // staged/unstaged sets — there's nothing to attribute (locked decision #2's "skip - // attribution" guard). Every cell renders as plain, undifferentiated change. - if role != Role::Combined || app.is_committed() { - return None; - } - let unstaged = app.role_change(idx, Role::Unstaged); - let staged = app.role_change(idx, Role::Staged); - Some(Attribution::build(unstaged, staged)) -} - -/// Resolve the [`AttributionMode`] to render `role` with, given the (possibly absent) -/// [`Attribution`] built by [`combined_attribution`] — absent for a non-combined role, OR for a -/// committed changeset's combined role (see that function's doc comment), in which case combined -/// renders [`AttributionMode::Plain`] rather than panicking. -fn attribution_mode(role: Role, attribution: &Option) -> AttributionMode<'_> { - match (role, attribution) { - (Role::Combined, Some(a)) => AttributionMode::Attributed(a), - (Role::Combined, None) => AttributionMode::Plain, - (Role::Unstaged, _) => AttributionMode::Plain, - (Role::Staged, _) => AttributionMode::StagedUniform, - } -} - -/// Whether a Del cell at `old_lnum` is staged, given `mode` — the single staged-ness decision -/// shared by [`del_bg_pair`] and [`del_tint_fg`] (locked decision #6: staged-ness must resolve -/// identically for background and foreground, not through a second path). -fn del_is_staged(mode: AttributionMode, old_lnum: u32) -> bool { - match mode { - AttributionMode::Plain => false, - AttributionMode::StagedUniform => true, - AttributionMode::Attributed(attribution) => attribution.del_is_staged(old_lnum), +/// Resolve the [`AttributionMode`] to render `role` with. +fn attribution_mode(role: Role) -> AttributionMode { + match role { + Role::Whole | Role::Unstaged => AttributionMode::Plain, + Role::Staged => AttributionMode::StagedUniform, } } -/// Whether an Add cell at `new_lnum` is staged, given `mode` — the single staged-ness decision -/// shared by [`add_bg_pair`] and [`add_tint_fg`] (locked decision #6). -fn add_is_staged(mode: AttributionMode, new_lnum: u32) -> bool { - match mode { - AttributionMode::Plain => false, - AttributionMode::StagedUniform => true, - AttributionMode::Attributed(attribution) => !attribution.add_is_unstaged(new_lnum), - } +/// Whether a changed cell is already staged, given `mode` — the single staged-ness decision every +/// resolver below shares, so a cell's background and its foreground can never disagree by +/// resolving through separate paths. +/// +/// Takes no line number: staged-ness is now a property of the pane's role alone. The per-cell +/// lookup this replaced belonged to the deleted whole-view attribution (ADR-038, "Delete +/// `attribute.rs` and its render integration"). +fn is_staged(mode: AttributionMode) -> bool { + matches!(mode, AttributionMode::StagedUniform) } -/// The (line, edit) background pair for a Del cell at `old_lnum`, given `mode`, resolved from -/// `theme`'s unstaged vs. staged Del tints. -fn del_bg_pair(mode: AttributionMode, old_lnum: u32, theme: &Palette) -> (Color, Color) { - let unstaged = (theme.del_line_bg, theme.del_edit_bg); - let staged = (theme.del_staged_line_bg, theme.del_staged_edit_bg); - if del_is_staged(mode, old_lnum) { - staged +/// The (line, edit) background pair for a Del cell, given `mode`, resolved from `theme`'s +/// unstaged vs. staged Del tints. +fn del_bg_pair(mode: AttributionMode, theme: &Palette) -> (Color, Color) { + if is_staged(mode) { + (theme.del_staged_line_bg, theme.del_staged_edit_bg) } else { - unstaged + (theme.del_line_bg, theme.del_edit_bg) } } -/// The (line, edit) background pair for an Add cell at `new_lnum`, given `mode`, resolved from -/// `theme`'s unstaged vs. staged Add tints. -fn add_bg_pair(mode: AttributionMode, new_lnum: u32, theme: &Palette) -> (Color, Color) { - let unstaged = (theme.add_line_bg, theme.add_edit_bg); - let staged = (theme.add_staged_line_bg, theme.add_staged_edit_bg); - if add_is_staged(mode, new_lnum) { - staged +/// The (line, edit) background pair for an Add cell, given `mode`, resolved from `theme`'s +/// unstaged vs. staged Add tints. +fn add_bg_pair(mode: AttributionMode, theme: &Palette) -> (Color, Color) { + if is_staged(mode) { + (theme.add_staged_line_bg, theme.add_staged_edit_bg) } else { - unstaged + (theme.add_line_bg, theme.add_edit_bg) } } -/// The tint foreground for a Del cell at `old_lnum`, given `mode`, resolved from `theme`'s -/// unstaged vs. staged Del foreground fields ([`Palette::del_fg`]/[`Palette::del_staged_fg`]). -fn del_tint_fg(mode: AttributionMode, old_lnum: u32, theme: &Palette) -> Color { - if del_is_staged(mode, old_lnum) { +/// The tint foreground for a Del cell, given `mode`, resolved from `theme`'s unstaged vs. staged +/// Del foreground fields ([`Palette::del_fg`]/[`Palette::del_staged_fg`]). +fn del_tint_fg(mode: AttributionMode, theme: &Palette) -> Color { + if is_staged(mode) { theme.del_staged_fg } else { theme.del_fg } } -/// The tint foreground for an Add cell at `new_lnum`, given `mode`, resolved from `theme`'s -/// unstaged vs. staged Add foreground fields ([`Palette::add_fg`]/[`Palette::add_staged_fg`]). -fn add_tint_fg(mode: AttributionMode, new_lnum: u32, theme: &Palette) -> Color { - if add_is_staged(mode, new_lnum) { +/// The tint foreground for an Add cell, given `mode`, resolved from `theme`'s unstaged vs. staged +/// Add foreground fields ([`Palette::add_fg`]/[`Palette::add_staged_fg`]). +fn add_tint_fg(mode: AttributionMode, theme: &Palette) -> Color { + if is_staged(mode) { theme.add_staged_fg } else { theme.add_fg @@ -550,7 +520,7 @@ enum Side { New, } -/// Horizontal-scroll left-edge marker (decision #7): replaces the first visible content column +/// Horizontal-scroll left-edge marker: replaces the first visible content column /// whenever a line actually had content panned off to the left. Dim-styled like the gap-row/ /// filler markers — no new color, just `theme.dim` on the existing `…` glyph. const HSCROLL_MARKER: &str = "…"; @@ -610,7 +580,8 @@ fn pan_spans(spans: Vec>, cols: usize, theme: &Palette) -> Vec>, cols: usize, theme: &Palette) -> Vec { - let (line_bg, edit_bg) = del_bg_pair(mode, n as u32, theme); + let (line_bg, edit_bg) = del_bg_pair(mode, theme); Some(LineEmphasis { line_bg, edit_bg, - tint_fg: del_tint_fg(mode, n as u32, theme), + tint_fg: del_tint_fg(mode, theme), }) } CellKind::Add => { - let (line_bg, edit_bg) = add_bg_pair(mode, n as u32, theme); + let (line_bg, edit_bg) = add_bg_pair(mode, theme); Some(LineEmphasis { line_bg, edit_bg, - tint_fg: add_tint_fg(mode, n as u32, theme), + tint_fg: add_tint_fg(mode, theme), }) } CellKind::Context | CellKind::Filler => None, @@ -886,7 +863,7 @@ fn build_pane_line( } } -/// Render one frame: SBS body (each pane painting its own 1-row header — CS1, `pane-headers`; +/// Render one frame: SBS body (each pane painting its own 1-row header — `pane-headers`; /// there's no more global header/winbar row), footer, and (when [`App::help_visible`]) the `?` /// overlay on top of everything else. `keymap` is the resolved, possibly-rebound keymap — the /// footer hint and help overlay render its ACTUAL bindings (see [`crate::keymap::footer_hint`]/ @@ -897,7 +874,8 @@ fn build_pane_line( pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette) { let area = frame.area(); - // CS10: reset every recorded hit region at the start of the frame — a region only survives + // Mouse support: reset every recorded hit region at the start of the frame — a region only + // survives // this frame if one of the panes below actually painted it again. Prevents a stale rect from // an earlier frame's layout (e.g. the outline just closed) from staying hit-testable. app.hit_regions = Default::default(); @@ -914,7 +892,7 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette ); } - // CS1 (`pane-headers`): no more standalone header row — the outline pane and the diff pane + // `pane-headers`: no more standalone header row — the outline pane and the diff pane // each paint their own 1-row header at the top of their own rect (`render_outline`/ // `render_body`), so `body_area` now claims the row the old global header/winbar used to // occupy. Every content row below keeps its exact prior y-coordinate: the row that moved out @@ -944,7 +922,7 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette render_outline(frame, app, outline_area, theme); // Spans the FULL body height, including row 0 — it now divides the two pane headers // (outline header vs. diff header) as well as the content rows below them; this reads - // fine in practice (CS1 risk noted, revisit if it looks heavy at review). + // fine in practice (risk noted, revisit if it looks heavy at review). for y in div_area.y..div_area.y + div_area.height { frame .buffer_mut() @@ -952,7 +930,7 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette } render_body(frame, app, diff_area, theme); } else { - // Closed: the diff takes the full body width — the exact M4 look (locked design). + // Closed: the diff takes the full body width — the exact original look (locked design). render_body(frame, app, body_area, theme); } @@ -961,7 +939,8 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette } } -/// Convert a ratatui [`Rect`] into the [`Region`] shape [`App::hit_regions`] stores (CS10) — +/// Convert a ratatui [`Rect`] into the [`Region`] shape [`App::hit_regions`] stores (mouse support) +/// — /// `app.rs` has no ratatui dependency, so every write into `hit_regions` goes through this. fn region_from(area: Rect) -> Region { Region { @@ -993,7 +972,8 @@ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { .split(vertical[1])[1] } -/// The `?` help overlay (CS3): a centered, bordered modal listing the focused view's + global +/// The `?` help overlay (the help footer and `?` overlay): a centered, bordered modal listing the +/// focused view's + global /// bindings, from the resolved `keymap` (never hardcoded — see [`crate::keymap::help_sections`]). /// Focused view = outline when the outline pane has focus, else diff. [`Clear`] wipes the popup /// area first so the diff content underneath doesn't show through the gaps between glyphs. @@ -1030,14 +1010,15 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect frame.render_widget(Paragraph::new(lines).block(block), popup_area); } -/// The style for a pane header/caption LABEL word (CS1, `focused-pane-header`), and — since +/// The style for a pane header/caption LABEL word (`focused-pane-header`), and — since /// `header-chrome-follows-focus` — the structural "identity" chrome that travels with it: the /// outline header's `[i/n]` counter, the diff header's `[fidx/nfiles]` counter, and the /// changeset-prefix segment's `[i/n] {title}` text. The SEMANTIC spans (diffstats, the /// needs-restack `⚠`, the current-changeset `●` marker, the pan-offset indicator) never use this -/// style — they keep their own colors regardless of focus (locked decision #2). `focused` selects -/// between [`Palette::pane_header_focused_fg`] with a structural, unconditional BOLD (locked -/// decision #3 — under [`Palette::mono`], where that color and `theme.dim` both collapse to +/// style — they keep their own colors regardless of focus (semantic spans keep their colors +/// regardless of focus). `focused` selects between [`Palette::pane_header_focused_fg`] with a +/// structural, unconditional BOLD (bold is structural, applied unconditionally — under +/// [`Palette::mono`], where that color and `theme.dim` both collapse to /// `Color::Reset`, this BOLD is the only thing that still marks the focused label) and the plain /// [`Palette::dim`] every unfocused label already used before this changeset. Exactly one /// header/caption across a frame's outline header / diff header / split captions should ever @@ -1055,20 +1036,22 @@ fn pane_header_label_style(theme: &Palette, focused: bool) -> Style { } } -/// The outline pane's own top row (CS1, `pane-headers`): `[i/n] {display_label}` (the active +/// The outline pane's own top row (`pane-headers`): `[i/n] {display_label}` (the active /// changeset's TRUE stack position, the counter and display label both styled via /// [`pane_header_label_style`] (the counter joined the toggle in `header-chrome-follows-focus`) — /// lit ([`Palette::pane_header_focused_fg`] + bold) while the outline has focus, dim otherwise -/// (CS1, `focused-pane-header` — locked decision #5's "outline focused" case); no current-marker +/// (`focused-pane-header` — the exactly-one-lit-label invariant's "outline focused" case); no +/// current-marker /// glyph, since this header is always describing the currently-active changeset, a redundant /// thing to mark), ` {warn_marker} needs restack` (`theme.warn_fg`, full text unlike the diff /// header's glyph-only prefix — see [`changeset_prefix_spans`]) when /// [`workon::Changeset::needs_restack`], and a changeset-total `+A -D` diffstat (the fold -/// `render_winbar` used to own, pre-CS1) skipped when [`App::files`] is empty (a Pending/Failed +/// `render_winbar` used to own, pre-`pane-headers`) skipped when [`App::files`] is empty (a +/// Pending/Failed /// changeset, ADR-037). Truncated to the outline's own width via [`Buffer::set_line`], exactly /// like every outline item row below it. /// -/// CS1 risk (accepted, not fixed here): in [`crate::outline::OutlineMode::Flat`], the item rows +/// Risk (accepted, not fixed here): in [`crate::outline::OutlineMode::Flat`], the item rows /// below dedupe a file across every changeset that touches it, with no changeset context of their /// own — this header still names only the single ACTIVE changeset, so it can read as narrower /// than what the (deduped, cross-stack) row list actually shows. Acceptable for now; a future @@ -1112,40 +1095,43 @@ fn render_outline_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palet .set_line(area.x, area.y, &line, area.width); } -/// Render the outline pane into `area`: row 0 is the pane's own header (CS1, `pane-headers` — see +/// Render the outline pane into `area`: row 0 is the pane's own header (`pane-headers` — see /// [`render_outline_header`]), skipped only when `area.height < 2` (a degenerate terminal has no /// room to spare); every row below is an outline item exactly as before this changeset — the /// header carve-out is why an item's absolute screen row hasn't moved (it used to start one row /// below the OLD global header, now it starts one row below the pane's OWN header instead). /// [`OutlineItem::Header`]s (Stack mode only) carry the changeset's position marker (green • for /// `cs.current`), a `[i/n]` TRUE-stack-position counter, an accented ([`Palette::heading_fg`]) -/// bold label (CS1, `outline-header-polish` — see [`changeset_title_spans`]'s doc comment), and -/// needs-restack glyph (amber ⚠, [`crate::theme::Palette::warn_fg`] — locked decision #9's outline -/// half); [`OutlineItem::File`]s carry an -/// indent, a two-column git-porcelain-style status matrix (CS3, `outline-status-xy` — see +/// bold label (`outline-header-polish` — see [`changeset_title_spans`]'s doc comment), and +/// needs-restack glyph (amber ⚠, [`crate::theme::Palette::warn_fg`] — the amber +/// needs-restack glyph's outline half); [`OutlineItem::File`]s carry an +/// indent, a two-column git-porcelain-style status matrix (`outline-status-xy` — see /// [`outline_status_spans`]'s doc comment for the X/Y-vs-single-letter split), and -/// the path — Flat/Stack rows (CS2) split it into `basename dim/dirname` (no suffix for a +/// the path — Flat/Stack rows (the smart path render and tighter tree indent) split it into +/// `basename dim/dirname` (no suffix for a /// root-level file); Tree/StackTree rows already carry the directory via ancestor Dir rows, so /// `path` there is just the bare basename. A COLLAPSED [`OutlineItem::Header`]/[`OutlineItem::Dir`] -/// row (CS5, `outline-fold`) additionally carries a trailing dim ` ▸ N` (`N` = hidden FILE rows +/// row (`outline-fold`) additionally carries a trailing dim ` ▸ N` (`N` = hidden FILE rows /// only), from [`App::outline_items_with_hidden_counts`]'s per-row marker count — an expanded row /// gets no chevron at all. The cursor row (the outline's OWN cursor — a separate coordinate space from the /// diff's [`App::cursor`]) gets the theme's cursor tint while the outline has focus, or the dimmer /// [`Palette::cursor_unfocused_bg`] while it's merely open (so the remembered position stays -/// legible even after focus returns to the diff). `&mut App` (CS2, precedent: [`render_body`] +/// legible even after focus returns to the diff). `&mut App` (the outline scrolloff viewport +/// and `g`/`G` jumps, precedent: [`render_body`] /// writing [`App::pane_height`]) — writes [`App::outline_height`] and re-derives /// [`App::derive_outline_scroll`] before painting from `app.outline.scroll`, giving the outline /// the same stateful scrolloff-margined viewport the diff panes already have, instead of the old /// transient bottom-anchor scroll computed fresh each frame. /// -/// CS2 (`outline-filter`, M11) adds a SECOND optional carve-out, below the pane header: a one-row +/// The outline fuzzy filter (`outline-filter`) adds a SECOND optional carve-out, below the pane +/// header: a one-row /// fuzzy-filter input, painted only while [`App::outline_filter_active`] (non-empty query OR the /// input has capture) — an unused filter leaves every row below exactly where it was before this /// changeset (the locked "zero regression" rule). A query that matches nothing still shows the /// (now item-less) outline body with a single dim "no matches" placeholder row rather than a /// blank pane. fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { - // CS1 risk: this `>= 2` guard must exist in BOTH pane renderers (see `render_body`'s matching + // Risk: this `>= 2` guard must exist in BOTH pane renderers (see `render_body`'s matching // carve-out) — a 1-row (or shorter) terminal has no room to spare for a header at all. let area = if area.height >= 2 { render_outline_header(frame, app, area, theme, app.outline_focused()); @@ -1172,7 +1158,8 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) let icons = app.icon_mode(); if items.is_empty() && !app.outline_filter_query().is_empty() { - // CS2: the filter matched nothing — a blank pane below the (still-visible) filter input + // The outline fuzzy filter: the filter matched nothing — a blank pane below the + // (still-visible) filter input // reads as broken, so paint an explicit placeholder rather than falling through to the // loop below (which would render nothing at all, same as any other empty row list). if area.height > 0 { @@ -1224,7 +1211,8 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) } } -/// CS2 (`outline-filter`): paint the one-row fuzzy-filter input at `area`'s first row — a leading +/// The outline fuzzy filter (`outline-filter`): paint the one-row fuzzy-filter input at `area`'s +/// first row — a leading /// `/` prompt glyph (vim cmdline feel) followed by the query. The row renders in two visibly /// distinct states, because capture (input vs row list) is otherwise indiscernible — the row is /// present in both: @@ -1261,9 +1249,10 @@ fn render_outline_filter_input(frame: &mut Frame, app: &App, area: Rect, theme: /// Render a tree-guide prefix from an [`OutlineItem::Dir`]/[`OutlineItem::File`] `guides` /// vector: every element but the last draws a continuing `│` (if that ancestor level was NOT /// its parent's last child) or blank space (if it was), and the last element draws the row's own -/// `╰─`/`├─` connector — CS4 rounds the last-child corner (`╰`, U+2570) from the square `└` -/// (U+2514); there's no widely-supported rounded "tee" glyph, so the non-last `├─` connector is -/// unchanged. CS2 tightens indent to 2 cols/level: continuation is `│ ` (bar + space, no third +/// `╰─`/`├─` connector — the outline's path-trie tree modes rounds the last-child corner +/// (`╰`, U+2570) from the square `└` (U+2514); there's no widely-supported rounded "tee" glyph, +/// so the non-last `├─` connector is unchanged. The smart path render and tighter tree indent +/// tightens indent to 2 cols/level: continuation is `│ ` (bar + space, no third /// column), and connectors (`├─`/`╰─`) carry no trailing space — the glyph that follows hugs the /// connector directly. fn tree_prefix(guides: &[bool]) -> String { @@ -1282,13 +1271,14 @@ fn tree_prefix(guides: &[bool]) -> String { s } -/// Placeholder glyph for an empty XY status column (CS3, `outline-status-xy`) — U+00B7 middle +/// Placeholder glyph for an empty XY status column (`outline-status-xy`) — U+00B7 middle /// dot, always `theme.dim`, standing in for "nothing to report on this axis." Deliberately not a /// space: the two-column matrix should read as a grid even when one side is empty, not look like /// a ragged single-letter row. const STATUS_PLACEHOLDER: char = '\u{b7}'; -/// A committed changeset's single-letter status color (CS3, CS11): A green ([`Palette::add_fg`]), +/// A committed changeset's single-letter status color (`outline-status-xy`, the diff +/// foreground/background split): A green ([`Palette::add_fg`]), /// D red ([`Palette::del_fg`]), M/R/C (a change to EXISTING content, not a create/destroy) the /// dedicated amber [`Palette::modified_fg`], and `?`/`U` dim (Untracked never reaches here — see /// [`outline_status_spans`]'s doc comment — and Unmerged is a worktree-only conflict state a @@ -1302,7 +1292,7 @@ fn committed_letter_color(change: FileStatus, theme: &Palette) -> Color { } } -/// Build a file row's two-column status matrix (CS3, `outline-status-xy`) — always exactly 2 +/// Build a file row's two-column status matrix (`outline-status-xy`) — always exactly 2 /// [`TSpan`]s' worth of display columns, in every mode, so committed and uncommitted rows stay /// aligned (the changeset's Gotcha). /// @@ -1317,9 +1307,9 @@ fn committed_letter_color(change: FileStatus, theme: &Palette) -> Color { /// underlying [`FileStatus`] — there's only one change kind per file, not separate staged/ /// unstaged kinds) in whichever column(s) that axis has a change, [`STATUS_PLACEHOLDER`] in the /// other; X (staged/index) is [`Palette::add_fg`] green, Y (worktree) is [`Palette::del_fg`] -/// red, matching git's own status convention. (CS11: these were the intensity-named edit-wash -/// background fields used as a foreground — read at ~1.6:1 contrast on a dark-wash theme; see -/// ADR-035's CS11 section.) +/// red, matching git's own status convention. (The diff foreground/background split: these +/// were the intensity-named edit-wash background fields used as a foreground — read at ~1.6:1 +/// contrast on a dark-wash theme; see ADR-035's diff-foreground/background-split section.) fn outline_status_spans( status: crate::outline::StagedStatus, change: FileStatus, @@ -1360,7 +1350,7 @@ fn outline_status_spans( } } -/// CS5 (`outline-fold`): a collapsed Header/Dir row's trailing marker — dim ` ▸ N`, `N` being the +/// `outline-fold`: a collapsed Header/Dir row's trailing marker — dim ` ▸ N`, `N` being the /// count of hidden FILE rows (not dirs) [`App::outline_items_with_hidden_counts`] attached to that /// row. `None` for `hidden == 0` (an EXPANDED Header/Dir — or a File row, which never carries a /// hidden count at all) — the locked "no chevron when expanded" rule reads a zero count as "don't @@ -1375,9 +1365,10 @@ fn fold_marker(hidden: usize, theme: &Palette) -> Option> { } /// Build one outline row's rendered [`Line`] — see [`render_outline`]'s doc comment for the -/// marker rules. `icons` (CS5, `workon.review.icons`) is [`IconMode::None`] by -/// default, which reproduces the pre-CS5 row text exactly (no icon glyph, no extra space); only -/// [`IconMode::Nerd`] inserts an icon before the name/path. `hidden` (CS5, `outline-fold`) is the +/// marker rules. `icons` (file-status letters and opt-in nerd icons, `workon.review.icons`) +/// is [`IconMode::None`] by default, which reproduces the pre-file-status-letters-and-opt-in- +/// nerd-icons row text exactly (no icon glyph, no extra space); only [`IconMode::Nerd`] inserts +/// an icon before the name/path. `hidden` (`outline-fold`) is the /// row's collapsed hidden-file count from [`App::outline_items_with_hidden_counts`] — `0` for /// every row that isn't a collapsed Header/Dir; see [`fold_marker`]. fn build_outline_line( @@ -1430,7 +1421,8 @@ fn build_outline_line( let dir_style = Style::default() .fg(theme.dim) .add_modifier(Modifier::ITALIC); - // CS2 (`outline-filter`): the prefix/icon and trailing slash are never part of the + // The outline fuzzy filter (`outline-filter`): the prefix/icon and trailing slash are + // never part of the // fuzzy-matched text (only `name` is — see `outline::filter_text`'s doc comment), so // only the `name` span runs through `highlight_filter_match`. let mut spans = vec![TSpan::styled( @@ -1451,10 +1443,11 @@ fn build_outline_line( } => { // Empty `guides` (Flat/Stack modes) keeps the original two-space indent; a // non-empty `guides` (Tree/StackTree modes) draws tree connectors instead — see - // `OutlineItem`'s doc comment for why emptiness is the mode signal. CS4: a non-empty + // `OutlineItem`'s doc comment for why emptiness is the mode signal. The outline's + // path-trie tree modes: a non-empty // prefix (real tree connectors) gets its own `theme.dim`-styled span — matching the // Dir row's already-dim guides — so the guide lines read as quiet structure, not part - // of the file's own status column. The status matrix itself (CS3, + // of the file's own status column. The status matrix itself (`outline-status-xy`, // `outline_status_spans`) is always exactly 2 display columns, same width the old // glyph+letter pair occupied, so this swap doesn't shift anything after it. let mut spans = Vec::new(); @@ -1495,11 +1488,13 @@ fn build_outline_line( } // Flat/Stack rows (empty `guides`) split `path` at render time into `basename dim/ // dirname` — basename first (bright, matching the tree modes' bare-name leaves) so - // truncation eats the dim dirname before the name a user is scanning for (CS2 - // gotcha). Tree/StackTree rows (non-empty `guides`) already carry the path via + // truncation eats the dim dirname before the name a user is scanning for (the smart + // path render and tighter tree indent gotcha). Tree/StackTree rows (non-empty + // `guides`) already carry the path via // ancestor Dir rows, so `path` there is already just the basename — render it as-is. // - // CS2 (`outline-filter`): `match_indices` are CHAR indices into the WHOLE `path` + // The outline fuzzy filter (`outline-filter`): `match_indices` are CHAR indices into + // the WHOLE `path` // field (see `outline::filter_text`'s doc comment), but the split-path case renders // `base` FIRST and `dir` SECOND — the reverse of `path`'s own dir-then-base order. // `dir_chars` re-partitions the indices into each rendered run's OWN local coordinate @@ -1556,7 +1551,8 @@ fn current_file_label(app: &App) -> String { } /// While [`App::hscroll`] is panned, a small dim `»42` (the column offset) appended to the diff -/// pane header (locked decision #8) — `None` at column `0`, matching the diffstat span's own +/// pane header (the changeset-position indicator) — `None` at column `0`, matching the diffstat +/// span's own /// present-or-absent pattern above/below. fn hscroll_indicator_span(app: &App, theme: &Palette) -> Option> { if app.hscroll == 0 { @@ -1568,17 +1564,18 @@ fn hscroll_indicator_span(app: &App, theme: &Palette) -> Option> )) } -/// CS1 (`pane-headers`)'s changeset-position prefix, prepended to the diff pane header only when +/// `pane-headers`'s changeset-position prefix, prepended to the diff pane header only when /// the outline is CLOSED and the stack has more than one changeset (see [`diff_header_line`]) — /// with the outline open, the outline pane's own header ([`render_outline_header`]) already /// carries this information, so showing it twice would be redundant. `[i/n] {display_label}`, /// plus a glyph-ONLY (no "needs restack" text — that's the outline header's fuller treatment) `⚠` /// in `theme.warn_fg` when [`workon::Changeset::needs_restack`]. Ported verbatim from the old -/// `render_winbar`'s equivalent prefix (locked decisions #8 + #9), minus the diffstat/path/icon -/// tail that moved into [`file_segment_spans`]. `focused` (CS1, `header-chrome-follows-focus`) +/// `render_winbar`'s equivalent prefix (the changeset-position indicator + needs-restack as a +/// boolean glyph in amber), minus the diffstat/path/icon +/// tail that moved into [`file_segment_spans`]. `focused` (`header-chrome-follows-focus`) /// is the same flag [`diff_header_line`]'s own label receives — the `[i/n] {title}` text lights /// and dims with it via [`pane_header_label_style`], while the warn glyph keeps its semantic -/// `theme.warn_fg` regardless (locked decision #2). +/// `theme.warn_fg` regardless (semantic spans keep their colors regardless of focus). fn changeset_prefix_spans( app: &App, theme: &Palette, @@ -1594,7 +1591,8 @@ fn changeset_prefix_spans( format!("[{i}/{n}] {title}"), pane_header_label_style(theme, focused), )]; - // A boolean-driven glyph + color (locked decision #9), not a title-string suffix — distinct + // A boolean-driven glyph + color (needs-restack as a boolean glyph in amber), not a + // title-string suffix — distinct // from the plain title so a stale-parent changeset reads as a heads-up at a glance. if cs.needs_restack { spans.push(TSpan::styled( @@ -1607,9 +1605,9 @@ fn changeset_prefix_spans( spans } -/// The diff pane header's shared "current file" segment (CS1, `pane-headers`): `[fidx/nfiles] ` +/// The diff pane header's shared "current file" segment (`pane-headers`): `[fidx/nfiles] ` /// and [`current_file_label`] both styled via [`pane_header_label_style`] (lit while `focused`, -/// dim otherwise — CS1, `focused-pane-header`; the counter joined the label's lit/dim toggle in +/// dim otherwise — `focused-pane-header`; the counter joined the label's lit/dim toggle in /// `header-chrome-follows-focus`, having previously stayed unconditionally bold), an optional /// nerd devicons file icon, a tight `+N -M` per-file diffstat (new: the old winbar only ever /// showed a CHANGESET-total @@ -1617,16 +1615,51 @@ fn changeset_prefix_spans( /// counts for a binary file as a text one, so this segment needs no binary special-case), and the /// pan-offset indicator. Used verbatim whether the outline is open, closed+lone, or closed+multi /// (with the changeset prefix ahead of it) — see [`diff_header_line`]'s state table. `focused` is -/// resolved by the caller from [`EffectiveZoom`] + focus state, not computed here (locked -/// decision #5: this segment is the diff pane header's own label, lit only when the diff has +/// resolved by the caller from [`EffectiveZoom`] + focus state, not computed here (the +/// exactly-one-lit-label invariant: this segment is the diff pane header's own label, lit only when +/// the diff has /// focus AND the effective zoom is [`EffectiveZoom::Single`] — under [`EffectiveZoom::Split`] a /// caption is the lit label instead, so this stays dim, EXCEPT when `render_body_split`'s own /// short-area fallback drops both captions, in which case this label lights up instead). +/// The role word the diff header carries when the current file renders as a SINGLE staged or +/// unstaged pane, or `None` when the header needs no such word. +/// +/// The split already answers "which role am I reading" with its two captions +/// ([`render_caption`]), and a single pane used to answer it with nothing at all — the same file +/// looks the same whether its one pane is the staged or the unstaged side. This closes that gap +/// for the three ways a single role happens: a file that is only staged, one that is only +/// unstaged, and a split maximized (`Z`) onto one of its halves. +/// +/// [`Role::Whole`] gets no word deliberately. It is reachable only for a binary file (which +/// renders a placeholder, not a diff) and for a committed changeset's files, where there is no +/// staged/unstaged distinction for a badge to disambiguate — see [`crate::app::effective_zoom`]. +/// The same uppercase wording as the split's captions, so `Z` reads as the caption moving into +/// the header rather than as a different label appearing. +/// +/// `split_collapsed` is [`render_body_split`]'s short-area fallback: under 4 body rows it drops +/// both captions and renders the focused half alone over the whole area, which is the one way a +/// `Split` zoom still puts a single role on screen with nothing naming it. The caller resolves +/// that (it owns the rect the fallback gates on) and this names the focused role for it — the +/// same division of labor as `focused`, whose own `area.height < 4` exception sits beside it. +fn single_role_badge(app: &App, split_collapsed: bool) -> Option<&'static str> { + let role = match app.effective_zoom_for(app.current) { + EffectiveZoom::Single(role) => role, + EffectiveZoom::Split if split_collapsed => app.split_focus_role(), + EffectiveZoom::Split => return None, + }; + match role { + Role::Unstaged => Some("UNSTAGED"), + Role::Staged => Some("STAGED"), + Role::Whole => None, + } +} + fn file_segment_spans( app: &App, theme: &Palette, icons: IconMode, focused: bool, + role_badge: Option<&'static str>, ) -> Vec> { let idx = app.current + 1; let n = app.files().len(); @@ -1634,6 +1667,12 @@ fn file_segment_spans( format!("[{idx}/{n}] "), pane_header_label_style(theme, focused), )]; + if let Some(label) = role_badge { + spans.push(TSpan::styled( + format!("{label} "), + pane_header_label_style(theme, focused), + )); + } if icons == IconMode::Nerd { if let Some(f) = app.files().get(app.current) { let (icon, color) = crate::icons::icon_for_path( @@ -1667,7 +1706,7 @@ fn file_segment_spans( spans } -/// The diff pane's own top-row header (CS1, `pane-headers` — replacing the old global +/// The diff pane's own top-row header (`pane-headers` — replacing the old global /// header/winbar row; see `render_body`'s header carve-out). Only covers the NON-summary states — /// [`render_body`] handles the summary-panel title separately, since that title comes from /// [`App::summary_for`] (called once per frame, not re-derived here). State table: @@ -1677,7 +1716,8 @@ fn file_segment_spans( /// - Outline closed + `changeset_count() > 1`: [`changeset_prefix_spans`], then a bold ` — ` /// separator, then [`file_segment_spans`] — the closed outline hides `]c`/`[c`'s (Diff-view /// bindings, `keymap.rs`) changeset-nav feedback, so this prefix keeps it visible. -/// - Outline closed + lone changeset: [`file_segment_spans`] alone (the pre-CS1 M4 look, now with +/// - Outline closed + lone changeset: [`file_segment_spans`] alone (the pre-`pane-headers` +/// original look, now with /// a per-file diffstat it never had before). /// - Pending/failed/empty `files()` (ADR-037): the changeset prefix alone if /// `changeset_count() > 1 && !outline_open()`, else a blank row — never a misleading `[1/0]`. @@ -1688,10 +1728,17 @@ fn file_segment_spans( /// `header_text_carries_the_theme_foreground_not_the_terminal_default`). /// /// `focused` is `true` only when the diff pane's OWN header label should be lit — the caller -/// ([`render_body`]) resolves this from the diff's focus state AND [`EffectiveZoom`] (locked -/// decision #5): a Split zoom lights a caption instead (see [`render_body_split`]), so this stays +/// ([`render_body`]) resolves this from the diff's focus state AND [`EffectiveZoom`] (the +/// exactly-one-lit-label invariant): a Split zoom lights a caption instead (see +/// [`render_body_split`]), so this stays /// dim even while the diff has focus in that case. -fn diff_header_line(app: &App, theme: &Palette, icons: IconMode, focused: bool) -> Line<'static> { +fn diff_header_line( + app: &App, + theme: &Palette, + icons: IconMode, + focused: bool, + role_badge: Option<&'static str>, +) -> Line<'static> { let show_prefix = app.changeset_count() > 1 && !app.outline_open(); if app.current_failure().is_some() || app.is_current_pending() || app.files().is_empty() { @@ -1715,13 +1762,14 @@ fn diff_header_line(app: &App, theme: &Palette, icons: IconMode, focused: bool) .add_modifier(Modifier::BOLD), )); } - spans.extend(file_segment_spans(app, theme, icons, focused)); + spans.extend(file_segment_spans(app, theme, icons, focused, role_badge)); Line::from(spans) } -/// Footer priority: a pending discard confirm's prompt (warn-toned) wins over the M11 CS3 search -/// prompt (while it has capture), which wins over a transient notice, which wins over the curated -/// hint line (CS3) — a notice TEMPORARILY REPLACES the hint rather than adding a second row; it +/// Footer priority: a pending discard confirm's prompt (warn-toned) wins over the in-diff +/// search prompt (while it has capture), which wins over a transient notice, which wins over the +/// curated hint line (the help footer and `?` overlay) — a notice TEMPORARILY REPLACES the hint +/// rather than adding a second row; it /// clears on the user's next keypress (`tui::update`). fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, theme: &Palette) { if let Some(confirm) = &app.pending_confirm { @@ -1738,7 +1786,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, them render_footer_notice_or_hint(frame, app, area, keymap, theme); } -/// M11 CS3 (`diff-search`): paint the one-row search prompt in the footer while it has capture — +/// The in-diff search (`diff-search`): paint the one-row search prompt in the footer while it has +/// capture — /// the same leading dim glyph + [`PromptState::render_line`] shape as the outline's fuzzy-filter /// input ([`render_outline_filter_input`]), just relocated to the footer (a vim-cmdline feel, /// per the plan) instead of a carve-out at the top of a pane. @@ -1784,7 +1833,7 @@ fn render_footer_notice_or_hint( } else { View::Diff }; - let text = footer_hint(keymap, focused, app.outline_mode()); + let text = footer_hint(keymap, focused, app.outline_mode(), app.can_stage_current()); frame.render_widget( Paragraph::new(text).style(Style::default().fg(theme.dim)), area, @@ -1820,7 +1869,8 @@ fn render_gap_row( buf.set_line(area.x, y, &line, area.width); } -/// Whether file `idx` needs CS4's deferred-load placeholder instead of its real diff: either the +/// Whether file `idx` needs idle-deferred file loads' deferred-load placeholder instead of its real +/// diff: either the /// current open is still pending (set by [`App::open_current`] in defer mode — see its doc /// comment), or it isn't pending but the view(s) its effective zoom needs haven't been loaded yet /// (e.g. a force-completed OTHER file's load left this one's cache untouched). Under @@ -1828,7 +1878,7 @@ fn render_gap_row( /// no change for the file stays legitimately `None` forever (see `ensure_role_loaded`), so /// gating on both panes would placeholder a one-role file for good. Once /// [`App::complete_pending_open`] runs, every loadable pane is loaded, and a role-less pane -/// renders empty exactly as it did pre-CS4. +/// renders empty exactly as it did pre-idle-deferred-file-loads. fn needs_deferred_placeholder(app: &App, idx: usize) -> bool { if app.open_pending() { return true; @@ -1842,7 +1892,8 @@ fn needs_deferred_placeholder(app: &App, idx: usize) -> bool { } } -/// Render CS4's deferred-load placeholder: a dim one-line paragraph naming the file, matching the +/// Render idle-deferred file loads' deferred-load placeholder: a dim one-line paragraph naming the +/// file, matching the /// existing binary-file placeholder's style (see `render_body`'s binary arm) so the two read as /// the same kind of "nothing to show yet" message. fn render_loading_placeholder( @@ -1938,9 +1989,9 @@ fn push_summary_body( /// Build a [`ChangesetSummary`]'s title spans (the same current/needs-restack markers /// `build_outline_line`'s Header arm draws, structurally shared via [`changeset_title_spans`] — -/// but passing `None` for that fn's `counter` param, so this title keeps its pre-CS1 plain- -/// foreground look with no `[i/n]` counter; see [`changeset_title_spans`]'s doc comment) and its -/// body lines: a loading/failed line OR the per-file list + totals line. CS1 (`pane-headers`) +/// but passing `None` for that fn's `counter` param, so this title keeps its pre-`pane-headers` +/// plain-foreground look with no `[i/n]` counter; see [`changeset_title_spans`]'s doc comment) +/// and its body lines: a loading/failed line OR the per-file list + totals line. `pane-headers` /// split the return into `(title, body)` — the title now paints the diff pane's header row /// ([`render_body`]), and the body no longer duplicates it as its own first line. fn changeset_summary_lines( @@ -1994,8 +2045,8 @@ fn changeset_summary_lines( /// Build a [`DirSummary`]'s title spans (a bold path line — no current/restack/loading/failed /// markers, a directory carries none of those; the title gets [`crate::icons::DIR_ICON`] in /// [`IconMode::Nerd`] mode, matching the outline's own [`OutlineItem::Dir`] row -/// (`build_outline_line`)) and its body lines (the per-file list + totals line). CS1 -/// (`pane-headers`): see [`changeset_summary_lines`]'s doc comment for why this returns a +/// (`build_outline_line`)) and its body lines (the per-file list + totals line). +/// `pane-headers`: see [`changeset_summary_lines`]'s doc comment for why this returns a /// `(title, body)` tuple now instead of one combined line list. fn dir_summary_lines( summary: &DirSummary, @@ -2026,10 +2077,10 @@ fn dir_summary_lines( (title, lines) } -/// CS4's summary panel: renders in place of the diff body while the outline is open and focused +/// The summary panel: renders in place of the diff body while the outline is open and focused /// with its cursor on a Header/Dir row (see [`App::summary_target`]) — per-file `"path +N -M"` /// rows (truncated to the pane height) and a totals line, painted into `area` (the diff pane's -/// header row is carved out by the caller, [`render_body`], before this ever runs — CS1, +/// header row is carved out by the caller, [`render_body`], before this ever runs — /// `pane-headers`). Returns the title [`Line`] so the caller can paint it into that header row; /// this fn itself paints only the body. A loading/failed Header shows its own inline state /// instead of a file list (see [`changeset_summary_lines`]). @@ -2052,9 +2103,9 @@ fn render_summary( fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { let icons = app.icon_mode(); - // CS1 (`pane-headers`): row 0 of the diff pane's own rect is its header — every branch below + // `pane-headers`: row 0 of the diff pane's own rect is its header — every branch below // (summary panel, pending/failed/empty, binary, normal file) shares this same carve-out, so - // it happens once, up front. CS1 risk: this `>= 2` guard must exist in BOTH pane renderers + // it happens once, up front. Risk: this `>= 2` guard must exist in BOTH pane renderers // (see `render_outline`'s matching carve-out) — a 1-row (or shorter) terminal has no room to // spare for a header at all, so `header_area` is `None` and `area` (shadowed below) stays the // full rect. Every content row keeps its exact prior y-coordinate: the row that moved out of @@ -2068,12 +2119,13 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { (None, area) }; - // CS4: the outline is open AND focused, and its cursor rests on a Header/Dir row — show that + // The summary panel: the outline is open AND focused, and its cursor rests on a Header/Dir row + // — show that // row's summary instead of a file's diff. Checked before every other body gate below (an // unfocused open outline, or the cursor on a File row, falls straight through to the usual // diff-body rendering; `summary_target` returns `None` in both cases). if let Some(target) = app.summary_target() { - // Built exactly once per frame (CS1 risk: never call `summary_for` twice) — its title + // Built exactly once per frame (risk: never call `summary_for` twice) — its title // spans paint the header row below, its body-only lines paint `render_summary`'s content. let summary = app.summary_for(target); let title = render_summary(frame, &summary, area, theme, icons); @@ -2086,11 +2138,13 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { } if let Some(header_area) = header_area { - // CS1 (`focused-pane-header`, locked decision #5): the diff header label lights up only + // `focused-pane-header` (the exactly-one-lit-label invariant): the diff header label lights + // up only // when the diff has focus AND its effective (not requested) zoom is `Single` — a `Split` // zoom lights the focused half's caption instead (see `render_body_split`), and the // outline holding focus dims every diff-side label. `effective_zoom_for` is cheap and - // already re-derived every frame elsewhere in this fn (locked decision #3), so no caching + // already re-derived every frame elsewhere in this fn (the per-file zoom gate is + // derived, never cached), so no caching // concern here either. // // Exception: `render_body_split`'s own short-area fallback (`area.height < 4`) renders @@ -2099,12 +2153,19 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { // gates on (both derive from the header carve-out above), so this branch mirrors that // check and lights the diff header instead, preserving the exactly-one-lit-label // invariant. + let split_collapsed = matches!( + app.effective_zoom_for(app.current), + EffectiveZoom::Split if area.height < 4 + ); let diff_header_focused = !app.outline_focused() && match app.effective_zoom_for(app.current) { EffectiveZoom::Single(_) => true, EffectiveZoom::Split => area.height < 4, }; - let line = diff_header_line(app, theme, icons, diff_header_focused); + // Same rect, same `< 4` question as `diff_header_focused` above — see + // `single_role_badge`. + let role_badge = single_role_badge(app, split_collapsed); + let line = diff_header_line(app, theme, icons, diff_header_focused, role_badge); frame .buffer_mut() .set_line(header_area.x, header_area.y, &line, header_area.width); @@ -2143,8 +2204,9 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { return; } - // CS4: in defer mode, selection changes never load — the diff body shows a placeholder until - // the event loop's idle window (`tui.rs`'s `OPEN_DEBOUNCE`) runs `complete_pending_open` + // Idle-deferred file loads: in defer mode, selection changes never load — the diff body + // shows a placeholder until the event loop's idle window (`tui.rs`'s `OPEN_DEBOUNCE`) runs + // `complete_pending_open` // between frames. Do NOT call `ensure_loaded` from this path in defer mode; outside defer mode // (the default), behavior is unchanged. if app.defer_loads() && needs_deferred_placeholder(app, idx) { @@ -2156,7 +2218,7 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { } // The gate re-evaluates the effective zoom for the current file every frame (no caching — - // ratatui relayout is free, per locked decision #3). + // ratatui relayout is free, per the per-file zoom gate is derived, never cached). match app.effective_zoom_for(idx) { EffectiveZoom::Single(role) => { app.pane_height = area.height as usize; @@ -2165,7 +2227,8 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { let cursor = Some(app.cursor); // The single pane is the focused one, so it shows any active selection. let selection = app.selection_range(); - // CS1 (`unfocused-cursor-wash`, locked decision #1): the single/combined diff body's + // `unfocused-cursor-wash` (the unfocused pane still paints a dim cursor row): the + // single/whole diff body's // cursor dims to the unfocused wash while the outline holds focus instead — it never // holds real focus itself in that state. let focused = !app.outline_focused(); @@ -2188,11 +2251,11 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { /// unstaged-content + caption(1) + staged-content, with the remainder halved between the two /// content panes (even split). fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, theme: &Palette) { - // CS1 (`focused-pane-header`, locked decision #5's split case): the outline holding focus + // `focused-pane-header` (the exactly-one-lit-label invariant's split case): the outline holding + // focus // dims BOTH captions (the outline header is the frame's one lit label); otherwise exactly the - // focused half's caption lights up, matching `split_focus_role()` — never derived from the - // requested `Zoom`, since this fn only ever runs once `effective_zoom_for` has already - // resolved to `Split` (see `render_body`'s caller). + // focused half's caption lights up, matching `split_focus_role()` — this fn only ever runs + // once `effective_zoom_for` has already resolved to `Split` (see `render_body`'s caller). let outline_focused = app.outline_focused(); // Too short to fit two captions plus a content line each: fall back to the focused pane alone, // rendered over the whole area, so the user still sees SOMETHING navigable. @@ -2240,7 +2303,8 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t app.clamp_scroll(); app.clamp_alt_scroll(); - // Each half's REAL focus (CS1, `unfocused-cursor-wash` — locked decisions #1/#5): the outline + // Each half's REAL focus (`unfocused-cursor-wash` — the unfocused-pane-still-paints-a- + // dim-cursor-row and exactly-one-lit-label invariants): the outline // holding focus means neither half does. Computed once here and reused by both // `render_caption` calls below and the pane render calls further down; a selection lives in // the focused pane only, so it gates on `focused` too (not on `cursor`, which the unfocused @@ -2329,7 +2393,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t /// runs to the right edge so the staged pane's caption row doubles as the horizontal divider /// between the split's two panes, matching the outline↔diff and side-by-side `│` rules (same /// `theme.dim`) without spending a dedicated divider row. The `──` rule characters always stay -/// `theme.dim` (locked decision #4, `focused-pane-header` — label text only); only the label +/// `theme.dim` (the lit/dim toggle covers label text only, `focused-pane-header`); only the label /// word itself takes [`pane_header_label_style`], lit while `focused`. fn render_caption(buf: &mut Buffer, area: Rect, label: &str, theme: &Palette, focused: bool) { let rule_style = Style::default().fg(theme.dim); @@ -2345,7 +2409,7 @@ fn render_caption(buf: &mut Buffer, area: Rect, label: &str, theme: &Palette, fo /// Render one SBS pane of `role`'s view for file `idx` into `area`, scrolled to `scroll`. The /// cursor-row highlight draws whenever `cursor` is `Some` and matches a visible row — this now -/// includes an unfocused split half's REMEMBERED cursor (CS1, `unfocused-cursor-wash`; previously +/// includes an unfocused split half's REMEMBERED cursor (`unfocused-cursor-wash`; previously /// unfocused passed `None` and drew no cursor at all). `focused` says which wash that row gets: /// full [`Palette::cursor_bg`] when this pane holds real focus, the dim /// [`Palette::cursor_unfocused_bg`] otherwise — resolved by the caller from app state @@ -2376,10 +2440,11 @@ fn render_pane_sbs( let old_area = hlayout[0]; let div_area = hlayout[1]; let new_area = hlayout[2]; - // One offset shared by every content pane (locked decision #1) — read once, before any of + // One offset shared by every content pane — read once, before any of // the `app` borrows below. let hscroll = app.hscroll; - // `workon.review.diff.text` (CS11) — read once per frame, same posture as `hscroll` above. + // `workon.review.diff.text` (the diff foreground/background split) — read once per + // frame, same posture as `hscroll` above. let text_mode = app.diff_text; // `App::search_matches` is computed only against the FOCUSED pane's view (see // `App::recompute_search`) — painting them on the other split pane too can slice its text at @@ -2395,10 +2460,7 @@ fn render_pane_sbs( let new_gutter_w = gutter_width(view.new_line_count()); let end = (scroll + area.height as usize).min(view.display.len()); - // Built once per frame, not per row/cached on `App` — see `combined_attribution`'s doc - // comment. `None` for non-combined roles, which don't need it. - let attribution = combined_attribution(app, idx, role); - let mode = attribution_mode(role, &attribution); + let mode = attribution_mode(role); // Phase 1 (mutable): populate the word-span cache for visible paired rows. Phase 2 below // re-borrows `app`/`view` immutably to build lines — kept as the same two-phase dance the @@ -2451,7 +2513,8 @@ fn render_pane_sbs( (Vec::new(), Vec::new()) }; - // M11 CS3: this row's (old, new) lineno pair — the same key `SearchMatch` carries + // The in-diff search: this row's (old, new) lineno pair — the same key + // `SearchMatch` carries // — resolved once and reused for both sides' highlight lookups below. let old_lineno = match row.old { Row::Line(n) => Some(n), @@ -2522,7 +2585,7 @@ fn render_pane_sbs( frame .buffer_mut() .set_line(new_area.x, y, &new_line, new_area.width); - // Right-edge hscroll marker (decision #7) — applied AFTER `set_line` (and thus + // Right-edge hscroll marker — applied AFTER `set_line` (and thus // after the cursor/selection wash above, which already painted the background) // so it survives on a cursor/selected row; `apply_right_edge_marker` only sets // `fg`, leaving whatever background the wash left in place. @@ -2611,23 +2674,24 @@ fn build_inline_line( let mut spans = vec![TSpan::styled(gutter, Style::default().fg(theme.gutter))]; let is_word_pair = row.is_word_diff_pair(); - // `kind` is always Del/Add/Context here — inline has no Filler rows. `old_opt`/`new_opt` - // carry the exact lineno each kind is documented to have (see this fn's own match above). + // `kind` is always Del/Add/Context here — inline has no Filler rows. Only the PRESENCE of + // `old_opt`/`new_opt` matters: a cell missing the lineno its kind is documented to carry gets + // no emphasis. The lineno's value stopped mattering when per-cell attribution was deleted. let emphasis = match kind { - CellKind::Del => old_opt.map(|n| { - let (line_bg, edit_bg) = del_bg_pair(mode, n as u32, theme); + CellKind::Del => old_opt.map(|_| { + let (line_bg, edit_bg) = del_bg_pair(mode, theme); LineEmphasis { line_bg, edit_bg, - tint_fg: del_tint_fg(mode, n as u32, theme), + tint_fg: del_tint_fg(mode, theme), } }), - CellKind::Add => new_opt.map(|n| { - let (line_bg, edit_bg) = add_bg_pair(mode, n as u32, theme); + CellKind::Add => new_opt.map(|_| { + let (line_bg, edit_bg) = add_bg_pair(mode, theme); LineEmphasis { line_bg, edit_bg, - tint_fg: add_tint_fg(mode, n as u32, theme), + tint_fg: add_tint_fg(mode, theme), } }), CellKind::Context | CellKind::Filler => None, @@ -2662,10 +2726,11 @@ fn render_pane_inline( theme: &Palette, focused: bool, ) { - // One offset shared by every content pane (locked decision #1) — read once, before any of + // One offset shared by every content pane — read once, before any of // the `app` borrows below. let hscroll = app.hscroll; - // `workon.review.diff.text` (CS11) — read once per frame, same posture as `hscroll` above. + // `workon.review.diff.text` (the diff foreground/background split) — read once per + // frame, same posture as `hscroll` above. let text_mode = app.diff_text; // See `render_pane_sbs`'s identical comment — gate search highlighting to the pane the // matches were actually computed against. @@ -2679,9 +2744,7 @@ fn render_pane_inline( let new_gutter_w = gutter_width(view.new_line_count()); let end = (scroll + area.height as usize).min(view.inline.len()); - // See `render_pane_sbs`'s identical comment — built once per frame, not cached on `App`. - let attribution = combined_attribution(app, idx, role); - let mode = attribution_mode(role, &attribution); + let mode = attribution_mode(role); // Same two-phase mutable/immutable dance as `render_pane_sbs`, over the inline coordinate // space instead. @@ -2725,7 +2788,8 @@ fn render_pane_inline( InlineRow::Add { .. } => &new_spans, _ => &[], }; - // M11 CS3: this row's (old, new) lineno pair, and which side's text is actually + // The in-diff search: this row's (old, new) lineno pair, and which side's text is + // actually // rendered here (a `Context` row renders `view.new_line` — see // `build_inline_line`'s own match — so it queries the New side; content is // identical to Old for a context row, and `compute_matches` only ever tags a @@ -2764,7 +2828,7 @@ fn render_pane_inline( line }; frame.buffer_mut().set_line(area.x, y, &line, area.width); - // Right-edge hscroll marker (decision #7) — see `render_pane_sbs`'s identical + // Right-edge hscroll marker — see `render_pane_sbs`'s identical // comment on ordering relative to the cursor/selection wash above. apply_right_edge_marker(frame.buffer_mut(), area, y, &line, theme); } @@ -2834,7 +2898,8 @@ mod tests { assert!(!segments[1].italic, "keyword segment stays upright"); } - // ── CS11: `workon.review.diff.text` (`DiffTextMode`) foreground selection ────────── + // ── The diff foreground/background split: `workon.review.diff.text` (`DiffTextMode`) + // foreground selection ───────────────────────────────────────────────── /// Every span's resolved foreground, in order — the shape these `content_spans` tests /// assert on, since `Style` doesn't expose its fg as a bare `Color` any other way. @@ -2844,7 +2909,8 @@ mod tests { #[test] fn content_spans_syntax_mode_ignores_tint_fg_entirely() { - // The changeset's primary gate (ADR-035 CS11): with `text_mode: Syntax`, `tint_fg` must + // The changeset's primary gate (ADR-035, the diff foreground/background split): with + // `text_mode: Syntax`, `tint_fg` must // never reach the output — a `Del`/`Add` line renders byte-identically whether `tint_fg` // is `Some` or `None`, for both a paired (word-diff) and an unpaired (excess) line. let theme = Palette::dark(); @@ -2897,7 +2963,8 @@ mod tests { #[test] fn content_spans_context_lines_never_take_tint_fg_in_any_mode() { - // Locked decision #3: the knob governs changed lines only. `emphasis: None` is what + // Context lines always keep syntax highlighting: the knob governs changed lines only. + // `emphasis: None` is what // marks a Context/Filler line — since `LineEmphasis` bundles the tint foreground with the // background wash it belongs to, `None` rules both out together by construction, so no // mode can paint a tint foreground with no edit/line wash for it to attach meaning to. @@ -2985,7 +3052,8 @@ mod tests { #[test] fn content_spans_edit_mode_paints_the_full_unpaired_line_matching_the_edit_wash() { - // Locked decision #4, the invariant this changeset exists to hold: an unpaired line (no + // An unpaired line's foreground follows its background wash — the invariant this + // changeset exists to hold: an unpaired line (no // word-diff counterpart) takes the edit background wash across its FULL width // (`content_spans`' own `is_word_pair == false` branch) — so in Edit mode it must take // the tint foreground across that exact same full width, never a subset and never none. @@ -3095,7 +3163,8 @@ mod tests { let mut app = app_from_fixture(&fixture); // `open_current` jumps the viewport straight to the first hunk row (the initial scroll - // behavior CS4 requires), so the leading gap before the hunk scrolls out of view — only + // behavior the staging-verbs work requires), so the leading gap before the hunk scrolls out + // of view — only // the trailing gap (after the hunk, before EOF) stays visible at the top of a // full-height render. app.open_current(); @@ -3182,7 +3251,7 @@ mod tests { ); } - // ── CS4: idle-deferred loads ────────────────────────────────────────────── + // ── Idle-deferred file loads ──────────────────────────────────────────────── #[test] fn defer_mode_shows_placeholder_and_does_not_load() { @@ -3202,7 +3271,7 @@ mod tests { content .iter() .any(|line| line.contains("tracked.txt") && line.contains("loading")), - "expected the CS4 loading placeholder, got:\n{}", + "expected the idle-deferred-file-loads loading placeholder, got:\n{}", content.join("\n") ); assert!( @@ -3220,7 +3289,8 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); - // `defer_loads` defaults off — render must still load eagerly, exactly like before CS4. + // `defer_loads` defaults off — render must still load eagerly, exactly like before + // idle-deferred file loads. let _ = render_once(&mut app, 60, 10); assert!( app.current_view_ref().is_some(), @@ -3619,271 +3689,6 @@ mod tests { } } - #[test] - fn single_pane_zoom_is_identical_to_combined_for_an_unstaged_only_file() { - // The common case: a dirty-but-unstaged file. The default split gate downgrades it to a - // single unstaged pane, whose view is byte-for-byte the combined view (index == HEAD when - // nothing is staged) — so a user who never presses `Z` sees exactly the pre-zoom app. - let old = "l1\nl2\nl3\nl4\nl5\nold word here\nl7\nl8\nl9\nl10\n"; - let new = "l1\nl2\nl3\nl4\nl5\nnew word here\nl7\nl8\nl9\nl10\n"; - let fixture = FixtureBuilder::new() - .config("core.autocrlf", "false") - .unstaged_file("small.txt", old, new) - .build() - .unwrap(); - - let mut app = app_from_fixture(&fixture); - app.open_current(); - let default_buf = render_once(&mut app, 60, 20); - - // No split chrome leaks into the single-pane render. - for line in buf_lines(&default_buf) { - assert!( - !line.contains("UNSTAGED") && !line.contains("STAGED"), - "single-pane render must not show a split caption, got line: {line:?}" - ); - } - - // Explicitly zoom to Combined and re-render — must be pixel-identical. - app.cycle_zoom(); - assert_eq!(app.zoom, crate::app::Zoom::Combined); - let combined_buf = render_once(&mut app, 60, 20); - assert_eq!( - default_buf, combined_buf, - "the default (downgraded-to-unstaged) render must match the combined-zoom render \ - cell-for-cell for an unstaged-only file" - ); - } - - #[test] - fn combined_view_colors_a_staged_change_dim_and_an_unstaged_change_bright() { - // A partially-staged file with two independent word changes: line 2 was already staged - // (committed -> staged both carry the change), line 4 is still only in the worktree - // (staged -> workdir carries it, index doesn't). The combined view (HEAD <-> worktree) - // fuses both into one set of rows — attribution must tell them apart: line 2's change - // should render with the dim (staged) pair, line 4's with the bright (not-yet-staged) - // pair, on BOTH the Del (old) and Add (new) side of each row (the add/del asymmetry: - // Del keys off the staged sub-diff, Add off the unstaged sub-diff). - let committed = "l1\nold word here\nl3\nold4 word four\nl5\n"; - let staged = "l1\nnew word here\nl3\nold4 word four\nl5\n"; - let workdir = "l1\nnew word here\nl3\nnew4 word four\nl5\n"; - let fixture = FixtureBuilder::new() - .config("core.autocrlf", "false") - .partially_staged_file("f.txt", committed, staged, workdir) - .build() - .unwrap(); - - let mut app = app_from_fixture(&fixture); - app.open_current(); - app.cycle_zoom(); // Split -> Combined - assert_eq!(app.zoom, crate::app::Zoom::Combined); - // Park the cursor on the file's first (context) row so its highlight tint doesn't blend - // into either changed row's background and muddy the color comparison below. - app.cursor = 0; - app.derive_scroll(); - - let buf = render_once(&mut app, 60, 20); - let content = buf_lines(&buf); - - let staged_row = content - .iter() - .position(|line| line.contains("old word here")) - .expect("staged change's old-side text visible"); - let unstaged_row = content - .iter() - .position(|line| line.contains("old4 word four")) - .expect("unstaged change's old-side text visible"); - - // Old (left) pane, first content column after the gutter — always carries SOME del - // emphasis on a changed row, line or edit depending on the word-diff split, but - // always from the dim family for a staged row and the bright family for an unstaged one. - let old_content_x = 4; // gutter width 3 + 1 space, same convention as the other tests - let staged_del_bg = buf - .cell((old_content_x, staged_row as u16)) - .unwrap() - .style() - .bg; - let unstaged_del_bg = buf - .cell((old_content_x, unstaged_row as u16)) - .unwrap() - .style() - .bg; - - let t = Palette::dark(); - let dim_dels = [Some(t.del_staged_line_bg), Some(t.del_staged_edit_bg)]; - let bright_dels = [Some(t.del_line_bg), Some(t.del_edit_bg)]; - assert!( - dim_dels.contains(&staged_del_bg), - "expected the staged row's Del side to use the dim pair, got {staged_del_bg:?}" - ); - assert!( - bright_dels.contains(&unstaged_del_bg), - "expected the unstaged row's Del side to use the bright pair, got {unstaged_del_bg:?}" - ); - assert_ne!( - staged_del_bg, unstaged_del_bg, - "staged and unstaged Del rows must render with visibly distinct backgrounds" - ); - - // New (right) pane: same rows carry "new word here" / "new4 word four" respectively. - let left_w = (buf.area.width.saturating_sub(1)) / 2; - let new_content_x = left_w + 1 + 4; // divider + gutter width 3 + 1 space - let staged_add_bg = buf - .cell((new_content_x, staged_row as u16)) - .unwrap() - .style() - .bg; - let unstaged_add_bg = buf - .cell((new_content_x, unstaged_row as u16)) - .unwrap() - .style() - .bg; - - let dim_adds = [Some(t.add_staged_line_bg), Some(t.add_staged_edit_bg)]; - let bright_adds = [Some(t.add_line_bg), Some(t.add_edit_bg)]; - assert!( - dim_adds.contains(&staged_add_bg), - "expected the staged row's Add side to use the dim pair, got {staged_add_bg:?}" - ); - assert!( - bright_adds.contains(&unstaged_add_bg), - "expected the unstaged row's Add side to use the bright pair, got {unstaged_add_bg:?}" - ); - assert_ne!( - staged_add_bg, unstaged_add_bg, - "staged and unstaged Add rows must render with visibly distinct backgrounds" - ); - } - - #[test] - fn combined_view_tint_mode_colors_a_staged_change_dim_fg_and_an_unstaged_change_bright_fg() { - // Full-stack companion to `combined_view_colors_a_staged_change_dim_and_an_unstaged_change_ - // bright`, same fixture/positions, but proving `workon.review.diff.text = tint`'s - // foreground threading end-to-end (App -> render_pane_sbs -> build_pane_line -> - // content_spans) rather than at the pure-function level: locked decision #6, the staged - // vs. unstaged tint foreground must follow the SAME attribution the backgrounds already - // use, not a second path. - let committed = "l1\nold word here\nl3\nold4 word four\nl5\n"; - let staged = "l1\nnew word here\nl3\nold4 word four\nl5\n"; - let workdir = "l1\nnew word here\nl3\nnew4 word four\nl5\n"; - let fixture = FixtureBuilder::new() - .config("core.autocrlf", "false") - .partially_staged_file("f.txt", committed, staged, workdir) - .build() - .unwrap(); - - let mut app = app_from_fixture(&fixture); - app.open_current(); - app.cycle_zoom(); // Split -> Combined - assert_eq!(app.zoom, crate::app::Zoom::Combined); - app.set_diff_text(DiffTextMode::Tint); - app.cursor = 0; - app.derive_scroll(); - - let buf = render_once(&mut app, 60, 20); - let content = buf_lines(&buf); - - let staged_row = content - .iter() - .position(|line| line.contains("old word here")) - .expect("staged change's old-side text visible"); - let unstaged_row = content - .iter() - .position(|line| line.contains("old4 word four")) - .expect("unstaged change's old-side text visible"); - - let old_content_x = 4; // gutter width 3 + 1 space, same convention as the sibling test - let staged_del_fg = buf - .cell((old_content_x, staged_row as u16)) - .unwrap() - .style() - .fg; - let unstaged_del_fg = buf - .cell((old_content_x, unstaged_row as u16)) - .unwrap() - .style() - .fg; - - let t = Palette::dark(); - assert_eq!( - staged_del_fg, - Some(t.del_staged_fg), - "the staged row's Del side must take del_staged_fg, not del_fg" - ); - assert_eq!( - unstaged_del_fg, - Some(t.del_fg), - "the unstaged row's Del side must take del_fg, not del_staged_fg" - ); - - let left_w = (buf.area.width.saturating_sub(1)) / 2; - let new_content_x = left_w + 1 + 4; - let staged_add_fg = buf - .cell((new_content_x, staged_row as u16)) - .unwrap() - .style() - .fg; - let unstaged_add_fg = buf - .cell((new_content_x, unstaged_row as u16)) - .unwrap() - .style() - .fg; - - assert_eq!( - staged_add_fg, - Some(t.add_staged_fg), - "the staged row's Add side must take add_staged_fg, not add_fg" - ); - assert_eq!( - unstaged_add_fg, - Some(t.add_fg), - "the unstaged row's Add side must take add_fg, not add_staged_fg" - ); - } - - #[test] - fn combined_view_syntax_mode_is_pixel_identical_regardless_of_attribution() { - // The changeset's primary identity gate, exercised on the exact fixture/positions the two - // tint-mode tests above use: with `diff.text` at its default (`Syntax`), the combined - // view's per-cell foreground for a staged AND an unstaged changed row must be unaffected - // by `DiffTextMode` — rendering under every mode variant applied to the SAME app state - // (only `diff_text` flipped) but reading it back to `Syntax` must reproduce the original - // frame exactly. - let committed = "l1\nold word here\nl3\nold4 word four\nl5\n"; - let staged = "l1\nnew word here\nl3\nold4 word four\nl5\n"; - let workdir = "l1\nnew word here\nl3\nnew4 word four\nl5\n"; - let fixture = FixtureBuilder::new() - .config("core.autocrlf", "false") - .partially_staged_file("f.txt", committed, staged, workdir) - .build() - .unwrap(); - - let mut app = app_from_fixture(&fixture); - app.open_current(); - app.cycle_zoom(); // Split -> Combined - app.cursor = 0; - app.derive_scroll(); - - assert_eq!( - app.diff_text, - DiffTextMode::default(), - "test setup: diff_text must start at its unset/syntax default" - ); - let baseline = render_once(&mut app, 60, 20); - - for mode in [DiffTextMode::Tint, DiffTextMode::Edit] { - app.set_diff_text(mode); - let _ = render_once(&mut app, 60, 20); // render under a tinting mode, then... - app.set_diff_text(DiffTextMode::Syntax); // ...switch back before comparing. - let restored = render_once(&mut app, 60, 20); - assert_eq!( - baseline, restored, - "Syntax mode must render identically to the pre-CS11 baseline regardless of \ - what DiffTextMode {mode:?} was live before it" - ); - } - } - #[test] fn footer_shows_hint_string_when_no_notice_is_set() { let fixture = FixtureBuilder::new() @@ -3911,8 +3716,9 @@ mod tests { .build() .unwrap(); let mut app = app_from_fixture(&fixture); - // A lone uncommitted changeset never auto-opens the outline (M4 default) — force it open - // + focused so `render_footer` takes the outline-focused branch. + // A lone uncommitted changeset never auto-opens the outline (the staging-verbs + // default) — force it open + focused so `render_footer` takes the outline-focused + // branch. app.toggle_outline(); assert!(app.outline_focused()); @@ -3928,8 +3734,8 @@ mod tests { crate::outline::OutlineMode::StackTree.label() )) && footer.contains("? help"), - "expected the curated outline hint string, with CS4's dynamic next-mode label \ - (Stack's default -> StackTree), in the footer, got: {footer:?}" + "expected the curated outline hint string, with `outline-mode-cycle`'s dynamic \ + next-mode label (Stack's default -> StackTree), in the footer, got: {footer:?}" ); } @@ -3973,11 +3779,17 @@ mod tests { use crate::config::View as CfgView; use crate::keymap::Keymap; + // A stageable file, not the empty fixture this used to build: the footer only carries a + // stage entry where staging can actually act (`App::can_stage_current`), and an empty + // changeset has nothing to stage. The rebinding under test is unrelated to that gate. let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") + .unstaged_file("f.txt", "l1\nl2\nl3\n", "l1\nCHANGED\nl3\n") .build() .unwrap(); let mut app = app_from_fixture(&fixture); + app.open_current(); + assert!(app.can_stage_current()); assert!(app.notice.is_none()); let keymap = Keymap::from_bindings(&[RawBinding { @@ -4062,10 +3874,10 @@ mod tests { ); } - // ── CS1 (`pane-headers`): outline header + diff header, replacing the old global winbar ──── + // ── `pane-headers`: outline header + diff header, replacing the old global winbar ────── /// Build a two-committed-changeset stack for the pane-header tests, hand-built the same way as - /// `app.rs`'s M5 CS1 tests (`Changeset` literal + `diff_changeset` + + /// `app.rs`'s stack-and-outline `pane-headers` tests (`Changeset` literal + `diff_changeset` + /// `ChangesetView::from_changeset_diff`): `cs-a` (`root..mid`, one file) then `cs-b` /// (`mid..head`, one file, `current` + `needs_restack`). fn two_committed_changesets_app(fixture: &Fixture) -> App { @@ -4126,7 +3938,8 @@ mod tests { #[test] fn outline_header_shows_changeset_position_title_and_restack_marker() { - // CS1: with the outline open (a two-changeset stack's default), the changeset-position + // `pane-headers`: with the outline open (a two-changeset stack's default), the + // changeset-position // context lives in the OUTLINE pane's own header, not the diff pane's — the outline // columns are x 0..35 at this width (see `OUTLINE_TEST_WIDTH`'s doc comment below). let fixture = FixtureBuilder::new() @@ -4155,7 +3968,8 @@ mod tests { #[test] fn diff_header_shows_the_active_files_position_diffstat_and_path_when_outline_open() { - // CS1: with the outline open, the diff header shows ONLY the file segment (no changeset + // `pane-headers`: with the outline open, the diff header shows ONLY the file segment (no + // changeset // prefix — the outline's own header already carries that) — diff columns are x 36.. at // this width. let fixture = FixtureBuilder::new() @@ -4174,8 +3988,9 @@ mod tests { header.contains("[1/1]") && header.contains("b.txt"), "expected the active file's position and path, got: {header:?}" ); - // CS4: a tight '+A -D' diffstat for the ACTIVE FILE (b.txt, one-line file, committed - // with no prior content, adds one line and deletes nothing) — CS1 is what made this + // The summary panel: a tight '+A -D' diffstat for the ACTIVE FILE (b.txt, one-line + // file, committed with no prior content, adds one line and deletes nothing) — + // `pane-headers` is what made this // PER-FILE (the old winbar only ever showed a changeset-total diffstat). assert!( header.contains("+1") && header.contains("-0"), @@ -4190,7 +4005,8 @@ mod tests { #[test] fn diff_header_carries_the_changeset_prefix_when_outline_closed() { - // CS1: closing the outline removes the pane that carried changeset-position context, so + // `pane-headers`: closing the outline removes the pane that carried changeset-position + // context, so // the diff header grows a `[i/n] — ` prefix ahead of // the file segment — this is what the old winbar used to show unconditionally. let fixture = FixtureBuilder::new() @@ -4305,7 +4121,7 @@ mod tests { let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); assert!( header.contains("[1/1]"), - "a lone changeset keeps the M4 `[fidx/nfiles]` file counter, got: {header:?}" + "a lone changeset keeps the original `[fidx/nfiles]` file counter, got: {header:?}" ); assert!( !header.contains('⚠'), @@ -4315,8 +4131,9 @@ mod tests { #[test] fn diff_header_shows_a_per_file_diffstat_for_a_lone_changeset() { - // CS1: new behavior — pre-CS1, the lone-changeset header never showed a diffstat at all - // (only the multi-changeset winbar did, and only a CHANGESET total). The file segment now + // `pane-headers`: new behavior — pre-`pane-headers`, the lone-changeset header never + // showed a diffstat at all (only the multi-changeset winbar did, and only a CHANGESET + // total). The file segment now // carries a per-file diffstat in every state, including this one. let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") @@ -4338,7 +4155,8 @@ mod tests { #[test] fn pending_changeset_diff_header_shows_no_file_counter() { - // ADR-037 + CS1: a Pending changeset's `files()` is always empty — the diff header must + // ADR-037 + `pane-headers`: a Pending changeset's `files()` is always empty — the diff + // header must // never show a misleading `[1/0]` file counter, whether the outline is open (a blank // row) or closed (the changeset prefix alone, still no file counter). use crate::app::ChangesetView; @@ -4417,14 +4235,17 @@ mod tests { } #[test] - fn committed_changeset_combined_view_skips_attribution_and_renders_plain() { - // A committed changeset's combined role has no staged/unstaged split to attribute - // against (`DiffState::from_committed` leaves both sub-models empty) — without the - // `is_committed` skip in `combined_attribution`, `Attribution::build(None, None)` would - // still run and its empty `unstaged_adds` set would make EVERY Add cell read as - // "already staged" (the dim pair), which is wrong: nothing here was staged from - // anything, it's a committed range. Assert the fix: the Add side renders the plain - // (bright) pair. + fn committed_changeset_whole_view_renders_plain() { + // Pre-ADR-038, `Role::Whole` on a committed changeset (no staged/unstaged split to + // attribute against — `DiffState::from_committed` leaves both sub-models empty) had a + // real failure mode: `Attribution::build(None, None)`'s empty `unstaged_adds` set would + // have made EVERY Add cell read as "already staged" (the dim pair) without an explicit + // `is_committed` skip. ADR-038, "Delete `attribute.rs` and its render integration", + // deletes `attribute.rs` entirely — `Role::Whole` + // now maps straight to `AttributionMode::Plain` (see `attribution_mode`), with no + // `Attributed` variant left to build an empty-set lookup from, so that failure mode is + // structurally unreachable rather than merely guarded. This test pins the still-real + // behavior it protects: the Add side renders the plain (bright) pair. use git2::Repository; use workon::{Changeset, ChangesetSpan}; @@ -4462,8 +4283,7 @@ mod tests { app.open_current(); assert!(app.is_committed()); // Park the cursor off the changed row so its highlight tint doesn't blend into the Add - // cell's background and muddy the color comparison below (same convention as - // `combined_view_colors_a_staged_change_dim_and_an_unstaged_change_bright`). + // cell's background and muddy the color comparison below. app.cursor = 0; app.derive_scroll(); @@ -4672,7 +4492,8 @@ mod tests { #[test] fn diff_header_shows_the_pan_offset_indicator_once_panned() { - // CS1: the pan indicator lives in the file segment, which the diff header always shows + // `pane-headers`: the pan indicator lives in the file segment, which the diff header always + // shows // (outline open or closed) — with the outline open (this stack's default), that's the // diff columns (x 36..) at this width. let fixture = FixtureBuilder::new() @@ -4720,7 +4541,7 @@ mod tests { ); } - // ── M5 CS3: outline side pane ─────────────────────────────────────────────── + // ── The outline side pane (flat and stack modes) ─────────────────────────── /// Every outline test renders at this width so the pane's fixed 35-col + 1-col-divider /// layout is unambiguous: columns `0..35` are the outline, `35` the divider, `36..` the @@ -4777,7 +4598,7 @@ mod tests { assert!(!app.outline_open()); let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); - // The diff's own content (the M4 full-width look) must reach all the way to the left + // The diff's own content (the original full-width look) must reach all the way to the left // edge — column 0 — rather than starting past a 36-column outline+divider offset. let row1: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 1)).collect(); assert!( @@ -4791,7 +4612,7 @@ mod tests { ); } - // ── CS2 (`outline-filter`, M11): fuzzy filter input row ───────────────────── + // ── The outline fuzzy filter (`outline-filter`): fuzzy filter input row ─────────────── #[test] fn outline_filter_input_row_renders_only_while_active() { @@ -4978,7 +4799,7 @@ mod tests { #[test] fn outline_header_shows_true_position_counter_regardless_of_display_order() { - // CS1 (`outline-header-polish`): the `[i/n]` counter is the TRUE stack position + // `outline-header-polish`: the `[i/n]` counter is the TRUE stack position // (`cs_idx + 1`), never a display-order index — HeadFirst (the default) paints cs-b // (true index 1) before cs-a (true index 0), so the counter must read `[2/2]` on cs-b's // row and `[1/2]` on cs-a's, in that display order, not `[1/2]` then `[2/2]`. @@ -5037,7 +4858,8 @@ mod tests { #[test] fn outline_header_truncates_to_the_pane_width() { - // CS1: `render_outline_header` writes via `Buffer::set_line(.., area.width)`, exactly + // `pane-headers`: `render_outline_header` writes via `Buffer::set_line(.., area.width)`, + // exactly // like every outline item row below it — a long changeset label must not bleed past the // outline's own width into the divider column (x=35 at `OUTLINE_TEST_WIDTH`). use crate::app::ChangesetView; @@ -5112,8 +4934,9 @@ mod tests { #[test] fn outline_items_still_start_at_y_1_below_the_outline_headers_own_row() { - // CS1 invariant: carving out row 0 for the outline's own header must not shift outline - // ITEM rows at all — they already started at y=1 pre-CS1 (below the OLD global header), + // `pane-headers` invariant: carving out row 0 for the outline's own header must not + // shift outline ITEM rows at all — they already started at y=1 pre-`pane-headers` + // (below the OLD global header), // and they still do now (below the outline's OWN header instead). The pane header itself // never shows the current-changeset marker (only an outline ITEM row does — see // `render_outline_header`'s doc comment), which makes the marker a clean signal for where @@ -5141,8 +4964,9 @@ mod tests { #[test] fn summary_panel_title_has_no_counter_and_keeps_the_plain_foreground_look() { - // CS1's Gotcha: the counter + accent are outline-only — the summary panel's title (shared - // via `changeset_title_spans`, `counter: None`) must render exactly as it did pre-CS1. + // `pane-headers`' Gotcha: the counter + accent are outline-only — the summary panel's + // title (shared via `changeset_title_spans`, `counter: None`) must render exactly as it + // did pre-`pane-headers`. let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .build() @@ -5160,7 +4984,8 @@ mod tests { app.focus_outline(); let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); - // CS1: the summary panel's title now paints the diff pane's OWN header row (y=0, x + // `pane-headers`: the summary panel's title now paints the diff pane's OWN header row (y=0, + // x // 36..) instead of the body's first line — include y=0 in the scan (no skip needed). // The OUTLINE pane's header (x <35) also shows a `[i/n]` counter for the same active // changeset, so this scan stays scoped to the diff-header/body slice (x 36..) to avoid @@ -5351,12 +5176,14 @@ mod tests { let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); - // Row order per the CS3 dirs-before-files/alpha-within-group rule, one outline row per + // Row order per the outline-side-pane (flat and stack modes) + // dirs-before-files/alpha-within-group rule, one outline row per // buffer row starting at y=1 (y=0 is the winbar): `src/` (dir, root, NOT the root's last // child — `top.txt` follows), `a.txt` nested one level under `src/` (the only — hence // last — child of `src/`), then `top.txt` (file, root, IS the root's last child). // - // CS2 tightens `tree_prefix` to 2 cols/level with no trailing space on the connector, so + // The smart path render and tighter tree indent tightens `tree_prefix` to 2 cols/level + // with no trailing space on the connector, so // these are exact-column checks (not just `contains`) — every rendered cell here is one // column wide, so `chars()` (not byte) indexing IS the display column (the guide glyphs // themselves are multi-byte, which is exactly why byte indexing would be wrong). @@ -5401,7 +5228,8 @@ mod tests { #[test] fn outline_file_row_tree_guide_carries_the_dim_color() { - // CS4: a File row's tree-guide connector (distinct from its status glyph, which keeps + // The outline's path-trie tree modes: a File row's tree-guide connector (distinct from its + // status glyph, which keeps // `theme.foreground`) is styled `theme.dim`, matching the Dir row's already-dim guides. let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") @@ -5434,7 +5262,7 @@ mod tests { ); } - // ── CS5 (`outline-fold`): collapse/expand marker ──────────────────────────────── + // ── `outline-fold`: collapse/expand marker ───────────────────────────────────── #[test] fn outline_collapsed_header_renders_a_trailing_dim_hidden_file_marker() { @@ -5543,7 +5371,7 @@ mod tests { ); } - // ── CS2 (outline-row-shape): smart path render ───────────────────────────────── + // ── The smart path render and tighter tree indent (outline-row-shape) ───────────── #[test] fn outline_stack_mode_file_row_splits_basename_and_dim_dirname() { @@ -5561,7 +5389,8 @@ mod tests { assert_eq!( app.outline_mode(), crate::outline::OutlineMode::Stack, - "sanity: default mode is Stack, so guides stay empty and this exercises CS2's split" + "sanity: default mode is Stack, so guides stay empty and this exercises the smart \ + path render's split" ); let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); @@ -5588,7 +5417,8 @@ mod tests { content[row_idx] ); - // Two blank columns separate the basename from the dirname (CS2: "basename dim/ + // Two blank columns separate the basename from the dirname (the smart path render + // and tighter tree indent: "basename dim/ // dirname"), so the dirname starts right after them. let dirname_x = basename_x + 5 + 2; assert_eq!( @@ -5641,7 +5471,7 @@ mod tests { fn outline_flat_row_truncation_eats_the_dim_dirname_first() { // A pane-width-exceeding Flat-mode row must truncate the (later, dim) dirname before it // ever touches the (earlier, bright) basename — that ordering is the whole point of - // basename-first rendering (CS2 gotcha). + // basename-first rendering (the smart-path-render gotcha). let long_dir = "reallyquiteverbosedirectoryname"; let path = format!("{long_dir}/x.txt"); let fixture = FixtureBuilder::new() @@ -5707,7 +5537,8 @@ mod tests { let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); - // The header row's own label is short, but CS1's `[i/n] ` counter widens it enough that a + // The header row's own label is short, but `outline-header-polish`'s `[i/n] ` counter + // widens it enough that a // single hscroll step no longer pans it fully off — it can now ALSO show a lone marker // plus a stray `a` (from a branch name like `main`), so a bare "contains 'a'" check no // longer picks out the PATH row unambiguously. Look for a run of the synthetic path's @@ -5745,7 +5576,7 @@ mod tests { ); } - // ── CS3 (`outline-status-xy`): git-style X/Y status matrix ───────────────────── + // ── `outline-status-xy`: git-style X/Y status matrix ─────────────────────────── /// Render `fixture` (a lone uncommitted changeset with one file at `path`) and return the /// buffer row + its char cells for the file row matching `path`. Skips y=0 (the winbar also @@ -6242,7 +6073,7 @@ mod tests { ); } - // ── CS3: nerd-mode status/header/summary iconography ──────────────────────── + // ── The nerd-mode status and header glyphs: nerd-mode status/header/summary iconography #[test] fn outline_header_nerd_markers_replace_the_unicode_defaults() { @@ -6254,7 +6085,8 @@ mod tests { app.set_icon_mode(crate::icons::IconMode::Nerd); let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); - // Skip y=0: it's the full-width winbar, which ALSO renders a (still-unicode, CS4's job) + // Skip y=0: it's the full-width winbar, which ALSO renders a (still-unicode, the + // tree-guide/gap/winbar restyle's job) // "⚠ needs restack" marker — an unskipped search would false-positive on it. let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); let joined = content.join("\n"); @@ -6279,7 +6111,8 @@ mod tests { #[test] fn outline_file_status_xy_column_is_unaffected_by_icon_mode() { - // CS3 retires StagedStatus's nerd/plain glyph split entirely — the X/Y status matrix is + // `outline-status-xy` retires StagedStatus's nerd/plain glyph split entirely — the X/Y + // status matrix is // now plain letters + `STATUS_PLACEHOLDER`, icon-mode-independent (only the devicons // per-file icon toggles on `IconMode::Nerd`). A fully staged file (`staged_file` writes a // brand-new path, so it's Added, not Modified) still renders `A·` whether or not nerd @@ -6344,7 +6177,7 @@ mod tests { ); } - // ── CS4: summary panel ─────────────────────────────────────────────────────── + // ── The summary panel ─────────────────────────────────────────────────────── /// The body area's columns, for a render at [`OUTLINE_TEST_WIDTH`] (outline `0..35`, divider /// `35`, body `36..`) — mirrors [`outline_row`]'s slice but for the OTHER side of the pane. @@ -6361,7 +6194,8 @@ mod tests { #[test] fn summary_header_shows_dir_title_and_body_drops_duplicate() { - // CS1: `dir_summary_lines` now returns `(title, body)` — the title paints the diff + // `pane-headers`: `dir_summary_lines` now returns `(title, body)` — the title paints the + // diff // pane's header row (y=0), and the body (per-file rows + totals) no longer repeats it as // its own first line. let fixture = FixtureBuilder::new() @@ -6641,7 +6475,7 @@ mod tests { ); } - // ── focused-pane-header (CS1): exactly-one-lit-label invariant ──────────────── + // ── focused-pane-header: exactly-one-lit-label invariant ─────────────────────── /// A cell's `(fg, bold?)` pair — the two axes [`pane_header_label_style`] toggles, checked /// together everywhere below since neither alone proves the invariant (a themed fg match with @@ -6656,7 +6490,7 @@ mod tests { // Gotcha: `App::from_changesets` defaults the outline open but UNFOCUSED, so at launch the // one lit label must be on the diff side, not the outline's — this is also the general // "diff focused, effective zoom Single" case, since a Committed changeset's file has no - // unstaged/staged split (always `EffectiveZoom::Single(Role::Combined)`). + // unstaged/staged split (always `EffectiveZoom::Single(Role::Whole)`). let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .build() @@ -6668,7 +6502,7 @@ mod tests { ); assert_eq!( app.effective_zoom_for(app.current), - EffectiveZoom::Single(Role::Combined) + EffectiveZoom::Single(Role::Whole) ); let theme = Palette::dark(); @@ -6694,7 +6528,8 @@ mod tests { #[test] fn outline_focused_lights_the_outline_header_and_dims_every_diff_side_label() { - // Locked decision #5's "outline focused" case: even a Split-zoom file's diff header AND + // The exactly-one-lit-label invariant's "outline focused" case: even a Split-zoom file's + // diff header AND // both of its captions must stay dim — the outline header is the frame's one lit label. let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") @@ -6754,7 +6589,8 @@ mod tests { #[test] fn split_zoom_lights_only_the_focused_halfs_caption_and_dims_the_diff_header() { - // Locked decision #5's "diff focused, effective zoom Split" case: the diff pane's OWN + // The exactly-one-lit-label invariant's "diff focused, effective zoom Split" case: the diff + // pane's OWN // header stays dim (there's no single file-wide label to light while two panes show), and // exactly the focused half's caption lights up — flipping `split_focus` flips which one. let fixture = FixtureBuilder::new() @@ -6812,6 +6648,221 @@ mod tests { check(&mut app, "STAGED", "UNSTAGED"); } + /// CS: the footer stops advertising `s stage` / `d discard` where a staging verb can only + /// refuse. A committed changeset is the common case (every file of a stack, PR, or ref + /// review); the keys stay bound and still explain themselves when pressed. + #[test] + fn a_committed_changesets_footer_does_not_advertise_the_staging_verbs() { + use git2::Repository; + use workon::{Changeset, ChangesetSpan}; + + use crate::app::ChangesetView; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("f.txt", "l1\nold\nl3\n") + .create("base") + .unwrap(); + let head = fixture + .commit("main") + .file("f.txt", "l1\nnew\nl3\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + + let cs = Changeset { + name: "main".to_string(), + span: ChangesetSpan::Committed { base, head }, + title: None, + current: true, + needs_restack: false, + }; + let diff = crate::acquire::diff_changeset(repo, &cs).unwrap(); + let view = ChangesetView::from_changeset_diff(cs, diff); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view]); + app.open_current(); + assert!(!app.can_stage_current()); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let footer = buf_lines(&buf).last().unwrap().clone(); + assert!( + !footer.contains("stage") && !footer.contains("discard"), + "a committed changeset's footer must not advertise staging, got: {footer:?}" + ); + assert!( + footer.contains("help"), + "the rest of the hint must survive, got: {footer:?}" + ); + } + + /// The mirror: an ordinary dirty file still advertises both. + #[test] + fn a_stageable_files_footer_still_advertises_the_staging_verbs() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("f.txt", "l1\nl2\nl3\n", "l1\nCHANGED\nl3\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert!(app.can_stage_current()); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let footer = buf_lines(&buf).last().unwrap().clone(); + assert!( + footer.contains("stage") && footer.contains("discard"), + "got: {footer:?}" + ); + } + + // ---- the single-role header badge ------------------------------------------------------- + + /// The header row's text for a freshly opened `app`, at a size roomy enough that nothing + /// wraps or truncates. + fn header_row(app: &mut App) -> String { + let buf = render_once(app, OUTLINE_TEST_WIDTH, 24); + buf_lines(&buf)[0].clone() + } + + #[test] + fn a_single_unstaged_pane_names_its_role_in_the_header() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("only.txt", "l1\nl2\nl3\n", "l1\nCHANGED\nl3\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert_eq!( + app.effective_zoom_for(app.current), + EffectiveZoom::Single(Role::Unstaged) + ); + + let header = header_row(&mut app); + assert!( + header.contains("UNSTAGED"), + "an unstaged-only file's header must name the role, got: {header:?}" + ); + assert!( + !header.contains("[1/1] STAGED"), + "and must not name the other one, got: {header:?}" + ); + } + + #[test] + fn a_single_staged_pane_names_its_role_in_the_header() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("new.txt", "hello\nthere\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert_eq!( + app.effective_zoom_for(app.current), + EffectiveZoom::Single(Role::Staged) + ); + + let header = header_row(&mut app); + assert!( + header.contains("STAGED"), + "a staged-only file's header must name the role, got: {header:?}" + ); + } + + /// The split already names both roles in its captions, so the header adds nothing — and + /// maximizing onto one half takes the badge back over, which is the `Z` continuity the badge + /// exists for. + #[test] + fn a_split_carries_no_header_badge_until_it_is_maximized() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .partially_staged_file( + "f.txt", + "alpha\nbeta\ngamma\n", + "alpha\nBETAEDIT\ngamma\n", + "alpha\nBETAEDIT\nGAMMAEDIT\n", + ) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert_eq!(app.effective_zoom_for(app.current), EffectiveZoom::Split); + + let header = header_row(&mut app); + assert!( + !header.contains("UNSTAGED") && !header.contains("STAGED"), + "the split's captions already name both roles, got: {header:?}" + ); + + app.toggle_split_focus(); // -> Staged + app.toggle_maximize(); + assert_eq!( + app.effective_zoom_for(app.current), + EffectiveZoom::Single(Role::Staged) + ); + let header = header_row(&mut app); + assert!( + header.contains("STAGED"), + "maximizing onto the staged half must carry its caption into the header, got: \ + {header:?}" + ); + } + + /// A committed changeset has no staged/unstaged split for a badge to disambiguate — see + /// `single_role_badge`. + #[test] + fn a_committed_changesets_whole_pane_carries_no_header_badge() { + use git2::Repository; + use workon::{Changeset, ChangesetSpan}; + + use crate::app::ChangesetView; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("f.txt", "l1\nold\nl3\n") + .create("base") + .unwrap(); + let head = fixture + .commit("main") + .file("f.txt", "l1\nnew\nl3\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + + let cs = Changeset { + name: "main".to_string(), + span: ChangesetSpan::Committed { base, head }, + title: None, + current: true, + needs_restack: false, + }; + let diff = crate::acquire::diff_changeset(repo, &cs).unwrap(); + let view = ChangesetView::from_changeset_diff(cs, diff); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view]); + app.open_current(); + assert_eq!( + app.effective_zoom_for(app.current), + EffectiveZoom::Single(Role::Whole) + ); + + let header = header_row(&mut app); + assert!( + !header.contains("UNSTAGED") && !header.contains("STAGED"), + "a committed changeset has no role to disambiguate, got: {header:?}" + ); + } + #[test] fn zoom_collapse_to_single_lights_the_diff_header_not_a_caption() { // Gotcha: a requested `Split` collapses to `EffectiveZoom::Single` for a file lacking one @@ -6826,11 +6877,7 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); app.open_current(); - assert_eq!( - app.zoom, - crate::app::Zoom::Split, - "default requested zoom is Split" - ); + assert!(!app.maximized, "default maximize is off"); assert_eq!( app.effective_zoom_for(app.current), EffectiveZoom::Single(Role::Unstaged), @@ -6840,9 +6887,13 @@ mod tests { let theme = Palette::dark(); let buf = render_once(&mut app, 60, 20); let content = buf_lines(&buf); + // Matched on the caption's `\u{2500}\u{2500} ` rule prefix, not the bare role word: the + // header's own single-role badge (`single_role_badge`) legitimately carries "UNSTAGED" + // here, so the word alone no longer distinguishes a caption from a badge. for line in &content { assert!( - !line.contains("UNSTAGED") && !line.contains("STAGED"), + !line.contains("\u{2500}\u{2500} UNSTAGED") + && !line.contains("\u{2500}\u{2500} STAGED"), "a collapsed Single zoom must not render split captions, got: {line:?}" ); } @@ -6881,9 +6932,11 @@ mod tests { let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 5); let content = buf_lines(&buf); + // The caption rule prefix, not the bare word — see the sibling collapse test. for line in &content { assert!( - !line.contains("UNSTAGED") && !line.contains("STAGED"), + !line.contains("\u{2500}\u{2500} UNSTAGED") + && !line.contains("\u{2500}\u{2500} STAGED"), "the short-area fallback must not render split captions, got: {line:?}" ); } @@ -6897,7 +6950,8 @@ mod tests { #[test] fn no_color_bold_is_the_only_focus_differentiator() { - // Locked decision #3: under `Palette::mono`, `pane_header_focused_fg` and `dim` both + // Bold is structural, applied unconditionally: under `Palette::mono`, + // `pane_header_focused_fg` and `dim` both // collapse to `Color::Reset` (see theme.rs's own // `mono_pane_header_focused_fg_collapses_with_dim_leaving_bold_the_only_differentiator`) // — this test proves `render.rs` itself still differentiates the focused label via BOLD @@ -6934,11 +6988,12 @@ mod tests { ); } - // ── unfocused-cursor-wash (CS1): the uniform dim-when-unfocused cursor model ─── + // ── unfocused-cursor-wash: the uniform dim-when-unfocused cursor model ────────── #[test] fn diff_cursor_dims_when_outline_holds_focus_single_zoom() { - // Locked decision #1: the diff body (single/combined zoom) paints its cursor row with the + // The unfocused pane still paints a dim cursor row: the diff body (single/whole zoom) + // paints its cursor row with the // dim unfocused wash, not full `cursor_bg`, whenever the outline (not the diff) holds // focus. let old = "l1\nl2\nl3\n"; @@ -6980,7 +7035,8 @@ mod tests { #[test] fn both_split_halves_dim_and_the_divider_carries_the_dim_wash_when_outline_holds_focus() { - // Locked decision #1's "outline-focused + split zoom" case: neither half holds focus, so + // The unfocused-pane-still-paints-a-dim-cursor-row decision's "outline-focused + split + // zoom" case: neither half holds focus, so // BOTH show the dim wash on their own remembered cursor row — and the gotcha this // changeset must fix, the divider cell re-tint on that row must follow the same wash // (previously hardcoded to full `cursor_bg`). @@ -7066,7 +7122,8 @@ mod tests { #[test] fn unfocused_split_half_shows_the_remembered_dim_cursor_while_the_focused_half_is_full() { - // Locked decision #1's diff-focused split case: the half that just LOST focus (`w` + // The unfocused-pane-still-paints-a-dim-cursor-row decision's diff-focused split case: + // the half that just LOST focus (`w` // toggled away from it) now shows its remembered cursor position in the dim wash, rather // than no cursor at all (the pre-changeset behavior — `pane_render_state` returned `None` // for the unfocused half). @@ -7140,12 +7197,13 @@ mod tests { ); } - // ── header-chrome-follows-focus (CS1): counters join the label's lit/dim toggle ─── + // ── header-chrome-follows-focus: counters join the label's lit/dim toggle ──────── #[test] fn outline_header_counter_follows_the_labels_focus_toggle() { // The outline header's `[i/n]` counter used to stay unconditionally bold+foreground — - // it now lights/dims together with the label beside it (locked decision #1). + // it now lights/dims together with the label beside it (structural chrome joins the + // label's lit/dim toggle). let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .build() @@ -7208,7 +7266,8 @@ mod tests { #[test] fn outline_header_diffstat_colors_stay_semantic_across_focus_toggle() { - // Locked decision #2: the `+N -M` diffstat span is semantic information, not identity + // Semantic spans keep their colors regardless of focus: the `+N -M` diffstat span is + // semantic information, not identity // chrome — it must keep its own color (and bold) regardless of which pane has focus. let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") @@ -7244,7 +7303,8 @@ mod tests { // `changeset_prefix_spans` (the diff header's changeset-position prefix, shown only with // the outline closed) splits into a `[i/n] {title}` text span that now follows the same // `focused` flag `diff_header_line` passes to `file_segment_spans`, and a glyph-only warn - // span that keeps `theme.warn_fg` regardless (locked decision #2) — exercised directly + // span that keeps `theme.warn_fg` regardless (semantic spans keep their colors + // regardless of focus) — exercised directly // rather than through a rendered frame to probe both flag values in isolation. (A real // frame CAN show the prefix dim: outline closed + Split zoom, where a caption is the lit // label and `diff_header_line` receives `focused == false`.) diff --git a/git-workon-review/src/scope.rs b/git-workon-review/src/scope.rs index 6e01076f..9087ce23 100644 --- a/git-workon-review/src/scope.rs +++ b/git-workon-review/src/scope.rs @@ -1,4 +1,4 @@ -//! Enclosing tree-sitter "scope" lookup for CS9's reveal-to-scope gap expansion. +//! Enclosing tree-sitter "scope" lookup used by the reveal-to-scope gap expansion. //! //! Pure module: given a language key (the same key //! [`crate::highlight::lang_key_for_ext`] resolves a file extension to) and a file's full text, diff --git a/git-workon-review/src/search.rs b/git-workon-review/src/search.rs index 25b3edf7..767e3b48 100644 --- a/git-workon-review/src/search.rs +++ b/git-workon-review/src/search.rs @@ -1,5 +1,6 @@ -//! Literal, smartcase text search over a file's pre-collapse diff rows (M11 CS3: `/` in the diff -//! view). [`compute_matches`] scans [`crate::align::AlignedRow`]s — the space BEFORE gap-collapse +//! Literal, smartcase text search over a file's pre-collapse diff rows (the in-diff search: `/` in +//! the diff view). [`compute_matches`] scans [`crate::align::AlignedRow`]s — the space BEFORE +//! gap-collapse //! — so a search sees hidden context exactly like it sees visible content; the caller (`app.rs`) //! is what auto-expands a gap a match lands inside, on jump. //! diff --git a/git-workon-review/src/source.rs b/git-workon-review/src/source.rs index e55b1f6e..7ca2cb30 100644 --- a/git-workon-review/src/source.rs +++ b/git-workon-review/src/source.rs @@ -5,8 +5,9 @@ //! `refs/heads/stack` or `heads/stack` classify as [`Source::Ref`]. Resolution //! ([`resolve_source`]) is where repo state comes in. //! -//! CS3 wires real `` dispatch (shape-aware: Graphite-tracked branch, other branch, -//! bare commit-ish) and `Range` (`a..b` / `a...b`, git-diff semantics). CS4 wires `Pr`: any +//! `` and range resolution wires real `` dispatch (shape-aware: Graphite-tracked +//! branch, other branch, bare commit-ish) and `Range` (`a..b` / `a...b`, git-diff semantics). +//! PR-reference resolution wires `Pr`: any //! form git-workon-lib's `parse_pr_reference` accepts (`pr-123`, `#123`, `pr#123`, GitHub URLs; //! a bare number never matches — that spelling stays a `Ref`), reused end-to-end for //! resolution too (`check_gh_available` → `fetch_pr_metadata` → fork-aware fetch → one @@ -54,7 +55,7 @@ pub enum Source { head_text: String, dots: RangeDots, }, - /// Everything else — a candidate ref, resolved by shape (CS3). + /// Everything else — a candidate ref, resolved by shape (`` and range resolution). Ref(String), } @@ -125,7 +126,7 @@ pub fn resolve_source( /// hinted pre-TUI error. The network round-trip (`check_gh_available`, `fetch_pr_metadata`, /// `fetch_branch_fresh`) lives entirely in this function so [`pr_changeset_from_metadata`] can /// stay a pure git2 mapping, fixture-testable without gh (the real gh path is exercised manually -/// — see the CS4 changeset description). +/// — see the PR-reference-resolution changeset description). /// /// Both refs are fetched with [`workon::fetch_branch_fresh`], not [`workon::fetch_branch`]: /// review's whole point is freshness, and `fetch_branch`'s existence short-circuit (right for @@ -453,7 +454,8 @@ fn trunk_commit_oid(repo: &Repository) -> Option { revparse_to_commit(repo, &name) } -/// Dynamic `[SOURCE]` completion candidates (ADR-036 "Completion" section, CS5): the `stack` / +/// Dynamic `[SOURCE]` completion candidates (ADR-036 "Completion" section, source completion +/// and sub-delegation): the `stack` / /// `uncommitted` keywords, plus local branch and tag names via offline git2 ref enumeration — /// never a PR number (network stays out of the TAB hot path). When `current` contains `..` or /// `...`, only the right-hand side is a ref candidate; each is emitted prefixed with the @@ -694,7 +696,8 @@ mod tests { ); } - // ── CS5: SOURCE completion — `split_range_rhs` (the pure half of `complete_source`) ───── + // ── Source completion and sub-delegation: `split_range_rhs` (the pure half of + // `complete_source`) ───────────────────────────────────────────────────────────── #[test] fn split_range_rhs_no_dots_is_whole_word() { @@ -713,7 +716,8 @@ mod tests { assert_eq!(split_range_rhs("main...fe"), ("main...", "fe")); } - // ── CS4: PR metadata → changeset mapping (the gh-free half of `resolve_pr`) ───────────── + // ── PR-reference resolution: PR metadata → changeset mapping (the gh-free half of + // `resolve_pr`) ───────────────────────────────────────────────────────────────── /// [`pr_changeset_from_metadata`] is the pure git2 half of PR resolution — everything /// downstream of `fetch_pr_metadata`/`fetch_branch`, which the real `gh` path can't exercise diff --git a/git-workon-review/src/stage_op.rs b/git-workon-review/src/stage_op.rs index dc0d1d1f..2d4bd825 100644 --- a/git-workon-review/src/stage_op.rs +++ b/git-workon-review/src/stage_op.rs @@ -1,4 +1,5 @@ -//! The concrete [`StagingOp`] the TUI enqueues for a hunk- or file-level staging verb (M4). +//! The concrete [`StagingOp`] the TUI enqueues for a hunk- or file-level staging verb, from the +//! staging-verbs work. //! //! ## The error seam //! @@ -26,7 +27,8 @@ use crate::synthesis::LineSelection; /// A queued staging action over one captured [`FileChange`]: a hunk op when `hunk_idx` is /// `Some`, a whole-file op when `None`. The `FileChange` is cloned at enqueue time (the file /// list is rebuilt on the next refresh), but the DIRECTION is fixed by `verb` at construction — -/// M4 uses deterministic pane-role direction (locked decision #1), not the queue's live-index +/// the staging-verbs work uses deterministic pane-role direction (locked decision: verbs act +/// only in the unstaged/staged panes; direction = pane role), not the queue's live-index /// toggle, so there's no snapshot-staleness to resolve inside `run`. /// /// Line-precise selections do NOT use this type — see [`LineSelectionOp`], which applies a diff --git a/git-workon-review/src/summary.rs b/git-workon-review/src/summary.rs index dea09925..b7a8d5c6 100644 --- a/git-workon-review/src/summary.rs +++ b/git-workon-review/src/summary.rs @@ -1,4 +1,4 @@ -//! CS4's summary panel: pure builders for the renderable data `render.rs`'s `render_summary` +//! The summary panel: pure builders for the renderable data `render.rs`'s `render_summary` //! paints when the outline is OPEN AND FOCUSED and its cursor rests on a //! [`crate::outline::OutlineItem::Header`]/[`crate::outline::OutlineItem::Dir`] row instead of a //! file — mirrors [`crate::outline`]'s pure-module posture (no [`crate::app::App`]/git2 @@ -125,8 +125,9 @@ pub struct DirSummary { /// SEGMENT prefix of `file_path` — `"src"` matches `"src/a.rs"` but must NOT match `"src2/b.rs"` /// (a raw [`str::starts_with`] would wrongly match the latter). /// -/// `pub(crate)`: CS7's `App::outline_row_targets` reuses this to resolve a Dir row's files -/// (`s`/`d` in the outline), the same segment-boundary rule [`dir_summary`] already relies on — +/// `pub(crate)`: the outline staging verbs' `App::outline_row_targets` reuses this to resolve a +/// Dir row's files (`s`/`d` in the outline), the same segment-boundary rule +/// [`dir_summary`] already relies on — /// rather than re-deriving it in `app.rs`. pub(crate) fn path_is_under(file_path: &str, dir_path: &str) -> bool { file_path diff --git a/git-workon-review/src/synthesis.rs b/git-workon-review/src/synthesis.rs index 936bdc53..46505c9b 100644 --- a/git-workon-review/src/synthesis.rs +++ b/git-workon-review/src/synthesis.rs @@ -8,15 +8,15 @@ //! rewrite. //! //! This module synthesizes WHOLE hunks (`[whole_hunk_patch]`) and line-precise selections -//! (`[partial_hunk_patch]`, traps 1-2: direction-dependent drop rules, the EOFNL splice). +//! (`[partial_hunk_patch]`: the direction-dependent drop rules, the no-newline-at-EOF splice). use std::collections::BTreeSet; use crate::error::SynthesisError; use crate::model::{FileChange, FileStatus, Hunk, LineKind}; -/// Which side of a patch is the "before" image — the direction-dependent drop rules (trap 1) -/// key off this. Whole-hunk patches don't drop lines, so `PatchBase` only affects +/// Which side of a patch is the "before" image — the direction-dependent drop rules key off +/// this. Whole-hunk patches don't drop lines, so `PatchBase` only affects /// [`partial_hunk_patch`] (and is otherwise threaded through by [`crate::apply::StageVerb::plan`] /// to pick which model a caller synthesizes from). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -215,7 +215,8 @@ impl PatchText { out } - /// Pure transform: swap old/new paths and invert every hunk (trap 1's Old/New base swap, + /// Pure transform: swap old/new paths and invert every hunk (the direction-dependent drop + /// rules' Old/New base swap, /// applied wholesale). Needed because `Repository::apply` has no reverse flag — a /// "reverse apply" is `invert()` then a forward apply. `invert(invert(p)) == p` (tested). pub fn invert(&self) -> PatchText { @@ -238,8 +239,9 @@ impl PatchText { /// - binary files ([`SynthesisError::BinaryFile`]) — no hunks exist to synthesize from. /// - statuses NEITHER a two-sided NOR a one-sided (creation) hunk patch can express /// ([`SynthesisError::LineSelectionUnsupported`]): `Deleted` (a hunk patch of a deletion would -/// stage an empty blob instead of removing the file — trap 3) and `Unmerged`. CS4's `ops.rs` -/// routes these statuses to `file_ops.rs` before synthesis is ever reached, so +/// stage an empty blob instead of removing the file — whole-file-ops fallback) and `Unmerged`. +/// The whole-file ops and routing layer's `ops.rs` routes these statuses to `file_ops.rs` +/// before synthesis is ever reached, so /// `LineSelectionUnsupported` is the variant callers see here — it's the closest existing /// error to "use the whole-file op instead," which is exactly its `help` text. /// `Copied` is treated like `Renamed` (both carry an `old_path`). @@ -289,7 +291,7 @@ fn selectable_hunk( } /// Synthesize a patch for the WHOLE of `file`'s hunk at `hunk_idx` — no line selection, so the -/// direction-dependent drop rules (trap 1) don't apply; the hunk's lines are copied verbatim. +/// direction-dependent drop rules don't apply; the hunk's lines are copied verbatim. /// /// Same refusals as [`selectable_hunk`]. pub fn whole_hunk_patch(file: &FileChange, hunk_idx: usize) -> Result { @@ -340,7 +342,7 @@ enum SpliceNeed { KeptDeletion, } -/// Trap 2: rewrite a deletion-shaped line that carries +/// No-newline-at-EOF splice: rewrite a deletion-shaped line that carries /// [`crate::model::HunkLine::missing_newline`] when a LATER emitted line is [`LineKind::Context`] /// — the shape that lets `git apply` silently corrupt content (see below). Two shapes reach /// here, tagged by [`SpliceNeed`]: a dropped-deletion-turned-context line (`base == Old`) and a @@ -466,7 +468,7 @@ pub struct LineSelection { } /// Synthesize a patch for a LINE-PRECISE selection of `file`'s hunk at `hunk_idx` — the -/// direction-dependent drop rules (trap 1). +/// direction-dependent drop rules. /// /// Context lines are always emitted as context. For the rest, `base` decides what happens to a /// line that ISN'T kept: @@ -533,7 +535,7 @@ pub struct LineSelection { /// deletion converted to context (see above) or a KEPT deletion emitted verbatim — followed by /// any other emitted line, is spliced by [`splice_eofnl_context_lines`] into git's canonical /// delete+re-add form rather than left as a raw context/deletion line — see that function's docs -/// for why (trap 2). +/// for why (no-newline-at-EOF splice). pub fn partial_hunk_patch( file: &FileChange, hunk_idx: usize, diff --git a/git-workon-review/src/terminal_query.rs b/git-workon-review/src/terminal_query.rs index f3ad7923..4d249fa4 100644 --- a/git-workon-review/src/terminal_query.rs +++ b/git-workon-review/src/terminal_query.rs @@ -1,4 +1,4 @@ -//! The `theme = auto` terminal-derivation probe (ADR-035, CS6). +//! The `theme = auto` terminal-derivation probe (ADR-035). //! //! `auto` derives the base16 *scheme* (syntax + monochrome ramp) from the terminal's own colors, //! so code in the diff is highlighted in the same palette the user's terminal already uses. It diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 64ad0985..3dd89964 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -1,10 +1,11 @@ //! The base16 color-scheme primitive and the colors the renderer resolves against it (ADR-035). //! //! This is the theming *primitive* — the resolved palette a frame is painted with — distinct from -//! [`crate::config::Theme`], which is the git-config *selection* (`auto`/`dark`/`light`). CS4 was -//! dark-only and behavior-preserving: [`Palette::dark`] reproduces M3–M5's hardcoded colors exactly. -//! CS5 adds [`Palette::light`] and wires [`crate::config::Theme`] to pick between them; CS6 adds the -//! terminal-derivation probe for `auto`. +//! [`crate::config::Theme`], which is the git-config *selection* (`auto`/`dark`/`light`). The +//! base16 palette primitive was dark-only and behavior-preserving: [`Palette::dark`] reproduces +//! the original hardcoded colors exactly. The curated light scheme adds [`Palette::light`] and +//! wires [`crate::config::Theme`] to pick between them; the terminal-derivation probe for +//! `theme=auto` adds that probe. //! //! ## Hybrid boundary (ADR-035, twice-revised) //! Colors that sit ON a tinted background — the diff add/del gradient, its staged variants, the @@ -17,7 +18,8 @@ //! and leaves [`Palette::paint_canvas`] `false` so a transparent/backgrounded terminal isn't //! painted over; the curated schemes and the probe's curated fallback set it `true`. //! -//! **CS2 revision:** semantic chrome — error/warn/current-marker colors — was previously ANSI/const +//! **Promoting semantic foregrounds to palette knobs:** semantic chrome — +//! error/warn/current-marker colors — was previously ANSI/const //! in `crate::render` (`FG_ERROR`/`FG_WARN`/`FG_CURRENT`), deliberately excluded from the palette on //! the reasoning that these colors never sit on a tint and are never a theme knob. That boundary is //! now revised: they ARE palette knobs ([`Palette::error_fg`]/[`Palette::warn_fg`]/ @@ -28,31 +30,36 @@ //! slots' reasoning; its diff washes also derive from the probed accents, while cursor/selection //! borrow curated washes — see [`Palette::from_terminal`]'s doc comment). //! -//! **CS1 addition (`outline-header-polish`):** [`Palette::heading_fg`] (base0C, cyan) is a fourth -//! semantic-chrome field, same reasoning and same three-scheme mapping as the CS2 trio above — +//! **Distinguishable changeset header rows addition (`outline-header-polish`):** +//! [`Palette::heading_fg`] (base0C, cyan) is a fourth +//! semantic-chrome field, same reasoning and same three-scheme mapping as the +//! promoting-semantic-foregrounds-to-palette-knobs trio above — //! it's the outline's changeset-header-row accent, used only there (see //! `render::changeset_title_spans`'s doc comment for the outline-only gating). //! -//! **CS3 addition (`outline-status-xy`):** [`Palette::modified_fg`] (base09, orange/amber) is a +//! **The git-style XY status matrix addition (`outline-status-xy`):** [`Palette::modified_fg`] +//! (base09, orange/amber) is a //! fifth semantic-chrome field, same three-scheme mapping again — the outline's committed-file //! "modified" tint (M/R/C letters). Deliberately a NEW field rather than reusing //! [`Palette::warn_fg`]: "this changeset needs restacking" and "this file was modified" are //! unrelated facts that happen to both want an amber tone, and collapsing them onto one field //! would make them un-independently themeable. //! -//! **CS1 addition (`user-configurable colors tier`):** the deferred "user-configurable colors" -//! tier from this module's original doc comment lands as [`ThemeOverrides`] — per-slot +//! **The user-configurable color-override keys addition:** the deferred "user-configurable +//! colors" tier from this module's original doc comment lands as [`ThemeOverrides`] — per-slot //! (`base00`–`base0f`) and per-tint (`del-line-bg`, `cursor-bg`, …) git-config keys under //! `workon.review.theme.*`, read by `config::ReviewConfig::theme_overrides` and applied via //! [`Palette::apply_overrides`] on top of whichever base (`dark`/`light`/`auto`'s probe) was //! already resolved. Named bundled schemes (`theme = solarized`) were explicitly deferred — -//! only the override-key tier landed; see ADR-035's CS1 revision note for the full table. +//! only the override-key tier landed; see ADR-035's user-configurable-color-override-keys +//! revision note for the full table. //! -//! **CS2 addition (`no-color-mono`):** the same tier's other deferred extra, `NO_COLOR` support, -//! lands as [`Palette::mono`] — an achromatic scheme `main.rs` substitutes, after theme +//! **The NO_COLOR monochrome rendering addition (`no-color-mono`):** the same tier's other +//! deferred extra, `NO_COLOR` support, lands as [`Palette::mono`] — an achromatic scheme +//! `main.rs` substitutes, after theme //! resolution AND override application, when `NO_COLOR` is set (env kill-switch: it wins over //! any override). See [`Palette::mono`]'s doc comment for the fg-vs-wash split and ADR-035's -//! CS2 revision note. +//! NO_COLOR-monochrome-rendering revision note. use ratatui::style::Color; @@ -65,7 +72,7 @@ pub struct Base16 { } impl Base16 { - /// base16-eighties.dark (Chris Kempson) — the scheme M3–M5's syntax accents were already drawn + /// base16-eighties.dark (Chris Kempson) — the scheme the syntax accents were already drawn /// from (`highlight.rs`'s `C_*` consts ARE these slots; see ADR-035). Reproduced here in full /// so `Palette::dark` is a faithful re-expression of the shipped dark colors. const EIGHTIES_DARK: Base16 = Base16 { @@ -134,15 +141,16 @@ pub(crate) fn parse_hex_color(s: &str) -> Option { } /// Per-slot base16 and per-tint color overrides, read from `workon.review.theme.*` git config -/// (CS1, user-configurable colors tier) and applied on top of an already-resolved [`Palette`] via +/// (the user-configurable color-override keys) and applied on top of an already-resolved +/// [`Palette`] via /// [`Palette::apply_overrides`]. `slots` is private — built only through [`ThemeOverrides::set_slot`] /// so the 0–15 index invariant lives in one place; the tint fields mirror /// [`Palette`]'s diff/cursor tint fields verbatim (same names, kebab-case in config). /// -/// **CS11 rename:** the old intensity-named tint fields became `del_line_bg`/`del_edit_bg`/… -/// (attribution-precision-named), and four new foreground fields -/// (`add_fg`/`del_fg`/`add_staged_fg`/`del_staged_fg`) were added — see ADR-035's "Revised (CS11, -/// diff foreground/background split)" section. Hard rename, no compat alias: the old kebab-case +/// **The diff foreground/background split rename:** the old intensity-named tint fields became +/// `del_line_bg`/`del_edit_bg`/… (attribution-precision-named), and four new foreground fields +/// (`add_fg`/`del_fg`/`add_staged_fg`/`del_staged_fg`) were added — see ADR-035's "Revised (diff +/// foreground/background split)" section. Hard rename, no compat alias: the old kebab-case /// keys just fall through to the unrecognized-key warning now. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ThemeOverrides { @@ -164,11 +172,13 @@ pub struct ThemeOverrides { pub cursor_unfocused_bg: Option, pub pane_header_focused_fg: Option, pub filler_fg: Option, - /// M11 CS3 (`diff-search`): every search match's highlight — a new, open-ended tint (no - /// scheme slot maps to it, so this is the ONLY way to set it; see [`Palette::search_match_bg`]'s + /// The in-diff search (`diff-search`): every search match's highlight — a new, + /// open-ended tint (no scheme slot maps to it, so this is the ONLY way to set it; see + /// [`Palette::search_match_bg`]'s /// doc comment for the derived defaults). pub search_match_bg: Option, - /// M11 CS3: the CURRENT search match's highlight, distinct from [`Self::search_match_bg`]. + /// The in-diff search: the CURRENT search match's highlight, distinct from + /// [`Self::search_match_bg`]. pub search_current_bg: Option, } @@ -218,8 +228,9 @@ fn linearize_channel(c: u8) -> f64 { } /// WCAG relative luminance (`0.2126 R + 0.7152 G + 0.0722 B` over linearized channels) — `None` -/// for a non-RGB color, which has no luminance to compute (CS11: this is the first contrast math -/// in this module beyond [`tint_toward`]'s per-channel lerp; see [`staged_foreground`]). +/// for a non-RGB color, which has no luminance to compute (the diff foreground/background +/// split: this is the first contrast math in this module beyond [`tint_toward`]'s per-channel +/// lerp; see [`staged_foreground`]). fn relative_luminance(color: Color) -> Option { match color { Color::Rgb(r, g, b) => Some( @@ -240,21 +251,25 @@ fn contrast_ratio(a: Color, b: Color) -> Option { Some((hi + 0.05) / (lo + 0.05)) } -/// Nominal dim ratio a staged foreground blends toward [`Palette::background`] (CS11, locked -/// decision #4) — `40%`, the starting point [`staged_foreground`] backs off from if it fails the +/// Nominal dim ratio a staged foreground blends toward [`Palette::background`] (staged +/// foregrounds dim toward the background, contrast-clamped) — `40%`, the starting point +/// [`staged_foreground`] backs off from if it fails the /// contrast floor. const STAGED_FG_DIM_RATIO: f32 = 0.40; /// The relative-luminance contrast floor a staged foreground must clear against that state's own -/// *edit* wash (CS11, locked decision #4) — measured, not a fixed dim ratio, because a theme whose +/// *edit* wash (staged foregrounds dim toward the background, contrast-clamped) — measured, not +/// a fixed dim ratio, because a theme whose /// staged wash equals its unstaged one collapses a flat 40% dim to unreadable contrast. const STAGED_FG_LUMINANCE_FLOOR: f64 = 3.0; -/// Derive a staged foreground (CS11): dim `accent` toward `background` by up to +/// Derive a staged foreground (the diff foreground/background split): dim `accent` toward +/// `background` by up to /// [`STAGED_FG_DIM_RATIO`], but back off toward the undimmed accent until the blended color clears /// [`STAGED_FG_LUMINANCE_FLOOR`] against `edit_bg` (that state's own edit wash — `add_staged_edit_bg` /// for [`Palette::add_staged_fg`], etc.). If even the fully undimmed accent fails the floor, use it -/// undimmed anyway — the derivation never invents a hue to force compliance (locked decision #4). +/// undimmed anyway — the derivation never invents a hue to force compliance (staged foregrounds +/// dim toward the background, contrast-clamped). /// Non-RGB inputs (never produced by a curated/probed constructor here, but see [`Palette::mono`], /// which doesn't call this) skip the clamp entirely, matching [`tint_toward`]'s own non-RGB /// pass-through. @@ -290,9 +305,11 @@ pub(crate) fn staged_foreground(accent: Color, background: Color, edit_bg: Color /// Whether a background color reads as "light" — a sum-of-channels luminance proxy (matching the /// reasoning in this module's tests) with the midpoint of the `0..=765` range as the threshold. /// Used to pick which curated scheme's diff/cursor tints a probed or fallback theme borrows -/// (CS6): a probed dark background reuses [`Palette::dark`]'s hand-tuned tints, a light one reuses +/// (the terminal-derivation probe for `theme=auto`): a probed dark background reuses +/// [`Palette::dark`]'s hand-tuned tints, a light one reuses /// [`Palette::light`]'s derived washes. A non-RGB color (never produced by the OSC probe) reads as -/// dark. `pub` (not `pub(crate)`) since CS2 (`no-color-mono`) also calls this from `main.rs`, +/// dark. `pub` (not `pub(crate)`) since NO_COLOR monochrome rendering (`no-color-mono`) also calls +/// this from `main.rs`, /// which depends on this lib crate externally, to pick [`Palette::mono`]'s ladder. pub fn is_light_background(color: Color) -> bool { match color { @@ -368,8 +385,8 @@ pub fn syntax_italic(capture: usize) -> bool { /// /// Syntax foreground is looked up per capture index via [`Palette::syntax`]; the diff-background /// gradient, its staged variants, and the cursor/selection/outline washes are read directly. All -/// values in [`Palette::dark`] reproduce the M3–M5 hardcoded colors exactly (CS4 is a -/// behavior-preserving refactor). +/// values in [`Palette::dark`] reproduce the original hardcoded colors exactly (the base16 +/// palette primitive is a behavior-preserving refactor). #[derive(Clone)] pub struct Palette { /// Per-capture syntax fg, indexed by the same capture index as @@ -377,14 +394,16 @@ pub struct Palette { syntax: Vec, /// Whole-line ("this line contains a deletion") / word-level edit ("this exact text IS the - /// deletion") background for an unstaged (bright) Del cell (CS11 renamed the old - /// intensity-named fields — the axis was always attribution precision, not intensity). + /// deletion") background for an unstaged (bright) Del cell (the diff foreground/background + /// split renamed the old intensity-named fields — the axis was always attribution precision, + /// not intensity). pub del_line_bg: Color, pub del_edit_bg: Color, /// Bright Add-cell background pair (counterpart of [`Palette::del_line_bg`]). pub add_line_bg: Color, pub add_edit_bg: Color, - /// Dim/desaturated Del pair for staged-ness attribution (locked decision #7) — a staged change + /// Dim/desaturated Del pair for staged-ness attribution (staged-ness attribution; a staged + /// wash reads dimmer) — a staged change /// reads as "already handled" without disappearing into plain context. pub del_staged_line_bg: Color, pub del_staged_edit_bg: Color, @@ -392,14 +411,17 @@ pub struct Palette { pub add_staged_line_bg: Color, pub add_staged_edit_bg: Color, - /// Foreground for added text (CS11) — the tint counterpart of [`Palette::add_edit_bg`]/ + /// Foreground for added text (the diff foreground/background split) — the tint counterpart + /// of [`Palette::add_edit_bg`]/ /// [`Palette::add_line_bg`], distinct from either so a wash can be used as a background AND a /// foreground can sit on top of unrelated backgrounds (e.g. the outline's X/Y status letters, /// [`crate::render::committed_letter_color`]/`outline_status_spans`, which is the bug this - /// field fixes — see ADR-035's CS11 section). Defaults to base0B, the same accent-slot mapping + /// field fixes — see ADR-035's diff-foreground/background-split section). Defaults to + /// base0B, the same accent-slot mapping /// [`Palette::error_fg`]/[`Palette::modified_fg`] already use for their base08/base09. pub add_fg: Color, - /// Foreground for deleted text (CS11) — counterpart of [`Palette::add_fg`], defaults to base08. + /// Foreground for deleted text (the diff foreground/background split) — counterpart of + /// [`Palette::add_fg`], defaults to base08. pub del_fg: Color, /// Staged counterpart of [`Palette::add_fg`] — dimmed toward [`Palette::background`], /// contrast-clamped against [`Palette::add_staged_edit_bg`] (see [`Palette::dark`]'s @@ -420,11 +442,13 @@ pub struct Palette { /// split halves adopted the same dim-when-unfocused model the outline already had. pub cursor_unfocused_bg: Color, - /// Foreground for the ONE pane header/caption label that currently holds focus (CS1, - /// `focused-pane-header` — locked decision #2). Defaults to [`Palette::foreground`] (base05); - /// an unfocused label keeps [`Palette::dim`] instead — there is no separate "unfocused" field. - /// [`crate::render`] always pairs this color with a structural, unconditional BOLD (locked - /// decision #3), so under [`Palette::mono`] (where this and [`Palette::dim`] both collapse to + /// Foreground for the ONE pane header/caption label that currently holds focus + /// (`focused-pane-header` — locked decision: one new `pane_header_focused_fg` palette + /// field). Defaults to [`Palette::foreground`] (base05); an unfocused label keeps + /// [`Palette::dim`] instead — there is no separate "unfocused" field. [`crate::render`] + /// always pairs this color with a structural, unconditional BOLD (locked decision: bold is + /// structural, applied unconditionally), so under [`Palette::mono`] (where this and + /// [`Palette::dim`] both collapse to /// `Color::Reset`) BOLD alone still marks the focused label. pub pane_header_focused_fg: Color, @@ -447,36 +471,41 @@ pub struct Palette { /// under `auto` on a terminal whose bright-black is a vivid accent rather than a gray (the /// probed ramp interpolates toward base03, so 2/3 of a bright accent is a bright hatch). pub filler_fg: Color, - /// M11 CS3 (`diff-search`): background wash for every search match — a warm accent + /// The in-diff search (`diff-search`): background wash for every search match — a warm accent /// (base0A-derived), distinct from every other row wash so it reads unambiguously over /// del/add/cursor/selection tints it composites with (see `render.rs`'s `compose_segments` /// bg-merge). pub search_match_bg: Color, - /// M11 CS3: background wash for the CURRENT search match — a more saturated step of the same - /// hue as [`Palette::search_match_bg`], so "here" reads distinctly from "also matches." + /// The in-diff search: background wash for the CURRENT search match — a more saturated + /// step of the same hue as [`Palette::search_match_bg`], so "here" reads distinctly from + /// "also matches." pub search_current_bg: Color, /// Footer text color for an [`crate::app::Severity::Error`] notice, a pending-discard confirm /// prompt, and a Failed changeset's marker/message — a clearly-red tone (base08). Promoted - /// from `render.rs`'s `FG_ERROR` const (CS2, revising ADR-035's hybrid boundary — see this + /// from `render.rs`'s `FG_ERROR` const (promoting semantic foregrounds to palette knobs, + /// revising ADR-035's hybrid boundary — see this /// module's doc comment). pub error_fg: Color, - /// Warning tone for a needs-restack marker (locked decision #9) — an amber (base0A), distinct + /// Warning tone for a needs-restack marker (needs-restack as a boolean glyph in amber) — an + /// amber (base0A), distinct /// from [`Palette::error_fg`]'s red: a stale-parent changeset is a heads-up to `gt restack`, - /// not a failure. Promoted from `render.rs`'s `FG_WARN` const (CS2). + /// not a failure. Promoted from `render.rs`'s `FG_WARN` const (promoting semantic foregrounds + /// to palette knobs). pub warn_fg: Color, - /// Tone for the outline's "this is the lib-marked `current` changeset" marker (locked - /// decision #9's outline half) — a green (base0B), distinct from every other marker color so + /// Tone for the outline's "this is the lib-marked `current` changeset" marker (needs-restack as + /// a boolean glyph in amber's outline half) — a green (base0B), distinct from every other + /// marker color so /// "current" reads unambiguously at a glance. Promoted from `render.rs`'s `FG_CURRENT` const - /// (CS2). + /// (promoting semantic foregrounds to palette knobs). pub current_fg: Color, - /// Accent tone for a changeset header row's label (CS1, `outline-header-polish`) — a cyan + /// Accent tone for a changeset header row's label (`outline-header-polish`) — a cyan /// (base0C), distinct from [`Palette::current_fg`]'s green so "this is a section heading" /// reads independently of "this is the current changeset." Used ONLY by the outline's Header /// rows (`render::changeset_title_spans`'s `counter` param gates it) — the summary panel's /// changeset title keeps the plain [`Palette::foreground`] look. pub heading_fg: Color, - /// Tone for a committed changeset's Modified/Renamed/Copied outline file-status letter (CS3, - /// `outline-status-xy`) — an amber (base09), distinct from [`Palette::warn_fg`]'s amber + /// Tone for a committed changeset's Modified/Renamed/Copied outline file-status letter + /// (`outline-status-xy`) — an amber (base09), distinct from [`Palette::warn_fg`]'s amber /// (base0A) so "needs restack" and "modified" stay independently themeable even though both /// default to the same amber family. Used ONLY by the outline's committed-file status column /// (`render::committed_letter_color`). @@ -486,8 +515,9 @@ pub struct Palette { /// (and the probe's curated fallback); `false` for [`Palette::from_terminal`], so `auto` /// preserves the terminal's own background (transparency, images) rather than flattening it. pub paint_canvas: bool, - /// Whether this palette carries no hue (CS2's `NO_COLOR` follow-up, `no-color-mono` - /// finding). `true` only for [`Palette::mono`]; `false` for every other constructor. Sources + /// Whether this palette carries no hue (NO_COLOR monochrome rendering's `NO_COLOR` follow-up, + /// `no-color-mono` finding). `true` only for [`Palette::mono`]; `false` for every other + /// constructor. Sources /// of color OUTSIDE the palette itself — namely [`crate::icons::icon_for_path`]'s hardcoded /// per-filetype `Rgb` — can't consult a palette field to know they should go achromatic, so /// `render.rs`'s icon paint sites check this flag directly and collapse to @@ -514,13 +544,15 @@ pub struct PaletteContext { } impl Palette { - /// The curated dark scheme: base16-eighties.dark accents + the M3–M5 hand-tuned diff/cursor - /// tints, reproduced byte-for-byte (the pixel-identity gate — see the module doc and ADR-035). + /// The curated dark scheme: base16-eighties.dark accents + the original hand-tuned + /// diff/cursor tints, reproduced byte-for-byte (the pixel-identity gate — see the module doc + /// and ADR-035). /// /// The diff-bg tints are held explicit rather than derived: a clean base08/base0B → base00 /// blend cannot reproduce these particular hand-tuned constants (their green/blue channels sit /// *below* base00, so no convex blend toward base00 reaches them). ADR-035's derivation is - /// therefore deferred to CS5, where the light scheme defines its own tints; dark keeps the + /// therefore deferred to the curated light scheme, where the light scheme defines its own + /// tints; dark keeps the /// shipped values verbatim. pub fn dark() -> Self { let base = Base16::EIGHTIES_DARK; @@ -539,8 +571,9 @@ impl Palette { del_staged_edit_bg, add_staged_line_bg: Color::Rgb(24, 34, 26), add_staged_edit_bg, - // CS11: brand new fields, no historical constant to reproduce — role-map to the - // accent slots, same reasoning as `heading_fg`/`modified_fg` below. + // The diff foreground/background split: brand new fields, no historical constant to + // reproduce — role-map to the accent slots, same reasoning as + // `heading_fg`/`modified_fg` below. add_fg: base.slot(11), del_fg: base.slot(8), add_staged_fg: staged_foreground(base.slot(11), base.slot(0), add_staged_edit_bg), @@ -554,21 +587,23 @@ impl Palette { dim: base.slot(3), gutter: base.slot(4), filler_fg: base.slot(1), - // M11 CS3: brand new, hand-tuned like the other dark-scheme washes (see `dark`'s doc - // comment on why dark tints are held explicit rather than derived) — a dim amber wash, - // brightening for the current match. + // The in-diff search: brand new, hand-tuned like the other dark-scheme washes (see + // `dark`'s doc comment on why dark tints are held explicit rather than derived) — a + // dim amber wash, brightening for the current match. search_match_bg: Color::Rgb(90, 80, 20), search_current_bg: Color::Rgb(150, 120, 20), - // The shipped M3–M5 semantic-chrome colors, reproduced verbatim (the pixel-identity - // gate — CS2 promotes these from `render.rs` consts without changing a single value). + // The shipped semantic-chrome colors, reproduced verbatim (the pixel-identity + // gate — promoting semantic foregrounds to palette knobs promotes these from + // `render.rs` consts without changing a single value). error_fg: Color::Rgb(220, 60, 60), warn_fg: Color::Rgb(214, 158, 46), current_fg: Color::Rgb(96, 200, 128), - // CS1: brand new (no historical `render.rs` const to reproduce), so this takes the - // scheme's base0C directly rather than an authored literal. + // Distinguishable changeset header rows: brand new (no historical `render.rs` const + // to reproduce), so this takes the scheme's base0C directly rather than an authored + // literal. heading_fg: base.slot(12), - // CS3: brand new, same reasoning as `heading_fg` above — takes the scheme's base09 - // directly rather than an authored literal. + // The git-style XY status matrix: brand new, same reasoning as `heading_fg` above — + // takes the scheme's base09 directly rather than an authored literal. modified_fg: base.slot(9), paint_canvas: true, colorless: false, @@ -629,8 +664,9 @@ impl Palette { dim: base.slot(3), gutter: base.slot(4), filler_fg: base.slot(1), - // M11 CS3: derived the same way as `cursor_bg`/`selection_bg` above — blend the - // scheme's amber (base0A) toward a light base00; the current match uses a shallower + // The in-diff search: derived the same way as `cursor_bg`/`selection_bg` above — + // blend the scheme's amber (base0A) toward a light base00; the current match uses a + // shallower // ratio (closer to the undimmed accent) so it reads more saturated than a plain match. search_match_bg: tint_toward(base.slot(10), base00, CURSOR), search_current_bg: tint_toward(base.slot(10), base00, EDIT), @@ -644,19 +680,21 @@ impl Palette { } } - /// A scheme derived from the terminal's own colors (ADR-035's `auto`, CS6). The 16 base16 + /// A scheme derived from the terminal's own colors (ADR-035's `auto`, the terminal-derivation + /// probe for `theme=auto`). The 16 base16 /// slots come from the probed [`Base16`] (built from the terminal's ANSI palette + background; /// see [`crate::terminal_query`]), so **syntax matches the terminal**. /// /// The **diff washes are derived from the probed accents** (the ADR-035 derived-washes - /// addendum, revising the CS6 curated-tints refinement): del washes blend probed base08 (ANSI + /// addendum, revising the terminal-derivation-probe-for-`theme=auto` curated-tints refinement): + /// del washes blend probed base08 (ANSI /// red) toward the probed background, add washes probed base0B (ANSI green) — the same /// `accent:mix(bg, 90)` arithmetic terminal theme authors use for their own editor diff /// backgrounds (laserwave's `BG_DELETE`/`BG_ADD`, the dogfood reference), so `auto`'s washes /// carry the terminal theme's hues instead of a generic red/green. Ratios are /// luminance-picked: a probed light background reuses [`Palette::light`]'s hand-tuned set; a /// dark one uses the dogfood-validated 10%/25% accent mixes (staged pushed further toward the - /// background, preserving locked decision #7's "staged reads dimmer"). + /// background, preserving the staged-ness-attribution decision's "staged reads dimmer"). /// /// The **cursor/selection washes stay curated by luminance**: they have no counterpart in a /// terminal theme's ANSI palette (deriving them from probed blue/cyan gives, e.g., a teal @@ -702,7 +740,7 @@ impl Palette { cursor_bg: curated.cursor_bg, selection_bg: curated.selection_bg, cursor_unfocused_bg: curated.cursor_unfocused_bg, - // M11 CS3: no probed-accent counterpart to derive from (same reasoning as + // The in-diff search: no probed-accent counterpart to derive from (same reasoning as // cursor/selection just above) — borrow the curated fallback's hand-tuned wash. search_match_bg: curated.search_match_bg, search_current_bg: curated.search_current_bg, @@ -730,13 +768,15 @@ impl Palette { } } - /// The achromatic scheme used when `NO_COLOR` is set (CS2, NO_COLOR support, `no-color.org`). + /// The achromatic scheme used when `NO_COLOR` is set (NO_COLOR monochrome rendering, + /// `no-color.org`). /// Every fg field (`foreground`/`dim`/`gutter`/`error_fg`/`warn_fg`/`current_fg`/ /// `heading_fg`/`modified_fg`), every [`Palette::syntax`] entry, and [`Palette::background`] /// collapse to `Color::Reset` — the terminal's own default fg/bg, nothing painted /// (`paint_canvas: false`, since Reset already means "don't touch"). `render.rs` has no /// non-color channel (reverse/dim modifiers) to fall back on for the 11 diff/cursor washes — - /// adding one is a render.rs change, out of CS2's scope (`render.rs` must not change) — so + /// adding one is a render.rs change, out of NO_COLOR monochrome rendering's scope + /// (`render.rs` must not change) — so /// those instead become achromatic (`r == g == b`) `Rgb` grayscale ladders: near-black for a /// dark terminal, near-white for a light one (picked by `light`, matching `main.rs`'s /// `is_light_background(theme.background)` call on the pre-mono base so `auto`'s probe still @@ -831,9 +871,11 @@ impl Palette { self.syntax[capture] } - /// Resolve the on-tint palette for a `workon.review.theme` selection (ADR-035/CS5) — the + /// Resolve the on-tint palette for a `workon.review.theme` selection (ADR-035, the curated + /// light scheme) — the /// **I/O-free** cases. `Light`/`Dark` return their curated schemes. `Auto` is the terminal - /// probe's job ([`crate::terminal_query::detect_auto_palette`], CS6), which needs tty access + /// probe's job ([`crate::terminal_query::detect_auto_palette`], the terminal-derivation + /// probe for `theme=auto`), which needs tty access /// this pure function can't have; `main.rs` routes `Auto` there and only falls through to this /// function's dark result if it declines to probe. A config-read error is likewise the /// caller's concern (see `main.rs`): this handles only a successfully-parsed selection. @@ -845,16 +887,17 @@ impl Palette { } } - /// Apply `workon.review.theme.*` overrides on top of an already-resolved palette (CS1, - /// user-configurable colors tier) — works the same on ANY base (`dark`/`light`/`auto`'s - /// probe result), applied last in `main.rs`'s resolution chain. + /// Apply `workon.review.theme.*` overrides on top of an already-resolved palette (the + /// user-configurable color-override keys) — works the same on ANY base + /// (`dark`/`light`/`auto`'s probe result), applied last in `main.rs`'s resolution chain. /// /// **Uniform slot rule:** a slot override rewrites every palette field role-mapped to that /// slot, regardless of which base authored the field's current value — base00 → /// [`Palette::background`] (and sets [`Palette::paint_canvas`], so an explicitly chosen /// background always paints, even under `auto`, which otherwise leaves the canvas - /// unpainted), base01 → [`Palette::filler_fg`], base02 → [`Palette::selection_bg`] (CS11: - /// these two were parsed but wired to nothing before — setting them failed silently), base03 + /// unpainted), base01 → [`Palette::filler_fg`], base02 → [`Palette::selection_bg`] (the + /// diff foreground/background split: these two were parsed but wired to nothing before — + /// setting them failed silently), base03 /// → [`Palette::dim`], base04 → [`Palette::gutter`], base05 → [`Palette::foreground`], base08 /// → [`Palette::error_fg`], base09 → [`Palette::modified_fg`], base0A → [`Palette::warn_fg`], /// base0B → [`Palette::current_fg`], base0C → [`Palette::heading_fg`], plus every @@ -986,7 +1029,8 @@ mod tests { fn dark_syntax_resolves_representative_captures_to_the_historical_colors() { let theme = Palette::dark(); let color = |name: &str| theme.syntax(capture_index(name).unwrap()); - // The exact C_* consts highlight.rs shipped in M3 (base16-eighties.dark accents). + // The exact C_* consts highlight.rs shipped with the initial renderer (base16-eighties.dark + // accents). assert_eq!(color("keyword"), Color::Rgb(0xcc, 0x99, 0xcc)); // C_PURPLE / base0E assert_eq!(color("string"), Color::Rgb(0x99, 0xcc, 0x99)); // C_GREEN / base0B assert_eq!(color("comment"), Color::Rgb(0x74, 0x73, 0x69)); // C_COMMENT / base03 @@ -997,7 +1041,8 @@ mod tests { #[test] fn dark_diff_tints_match_the_historical_constants() { - // The pixel-identity gate: `Palette::dark` must reproduce M3–M5's hand-tuned tints exactly. + // The pixel-identity gate: `Palette::dark` must reproduce the original hand-tuned tints + // exactly. // Pinned to the literals so a future refactor can't silently drift dark. let t = Palette::dark(); assert_eq!(t.del_line_bg, Color::Rgb(60, 24, 24)); @@ -1015,7 +1060,8 @@ mod tests { #[test] fn dark_semantic_fg_matches_the_historical_render_rs_constants() { - // CS2's pixel-identity gate for the promoted `FG_ERROR`/`FG_WARN`/`FG_CURRENT` consts. + // The promoting-semantic-foregrounds-to-palette-knobs pixel-identity gate for the + // promoted `FG_ERROR`/`FG_WARN`/`FG_CURRENT` consts. let t = Palette::dark(); assert_eq!(t.error_fg, Color::Rgb(220, 60, 60)); assert_eq!(t.warn_fg, Color::Rgb(214, 158, 46)); @@ -1024,7 +1070,8 @@ mod tests { #[test] fn dark_heading_fg_takes_the_eighties_dark_cyan_accent() { - // CS1: no historical constant to reproduce (this field is new) — unlike + // Distinguishable changeset header rows: no historical constant to reproduce (this field is + // new) — unlike // `dark_semantic_fg_matches_the_historical_render_rs_constants` above, it takes base0C // straight from the scheme. let t = Palette::dark(); @@ -1033,7 +1080,8 @@ mod tests { #[test] fn dark_modified_fg_takes_the_eighties_dark_orange_accent() { - // CS3: no historical constant to reproduce (this field is new, same reasoning as + // The git-style XY status matrix: no historical constant to reproduce (this field is new, + // same reasoning as // `dark_heading_fg_takes_the_eighties_dark_cyan_accent` above) — takes base09 straight // from the scheme. let t = Palette::dark(); @@ -1059,7 +1107,8 @@ mod tests { #[test] fn pane_header_focused_fg_defaults_to_foreground_and_is_distinct_from_dim() { - // CS1 (`focused-pane-header`, locked decision #2): the new tint field defaults to the + // `focused-pane-header` (locked decision: one new `pane_header_focused_fg` palette + // field): the new tint field defaults to the // normal foreground, not an independently authored color — and it must read distinct from // `dim` (the unfocused label's color) in every curated/probed scheme, mirroring the // contrast checks around `mono_washes_are_achromatic_and_preserve_the_curated_invariants`. @@ -1267,8 +1316,8 @@ mod tests { #[test] fn from_terminal_staged_washes_read_dimmer_than_unstaged() { - // Locked decision #7 survives derivation: a staged wash sits closer to the background - // than its unstaged counterpart (a strictly larger blend toward bg). + // The staged-ness-attribution decision survives derivation: a staged wash sits closer to + // the background than its unstaged counterpart (a strictly larger blend toward bg). let bg = Color::Rgb(0x1a, 0x1a, 0x1a); let probed = probed_base16(bg); let palette = Palette::from_terminal(probed); @@ -1368,7 +1417,8 @@ mod tests { Palette::for_theme(Theme::Dark).del_line_bg, Palette::dark().del_line_bg ); - // CS6: terminal-derive — Auto falls back to dark until the probe lands. + // The terminal-derivation probe for `theme=auto`: terminal-derive — Auto falls back to + // dark until the probe lands. assert_eq!( Palette::for_theme(Theme::Auto).del_line_bg, Palette::dark().del_line_bg @@ -1407,7 +1457,8 @@ mod tests { #[test] fn staged_foreground_backs_off_when_the_staged_wash_equals_the_unstaged_one() { - // The motivating failure (ADR-035 CS11): a theme whose staged edit wash equals its + // The motivating failure (ADR-035, the diff foreground/background split): a theme whose + // staged edit wash equals its // unstaged one collapses a flat 40% dim of the accent to unreadable contrast. The // derivation must back off to a smaller ratio that still clears the floor, rather than // returning the nominal (contrast-failing) dim. @@ -1467,7 +1518,8 @@ mod tests { /// The [`staged_foreground`] contract: the result either clears the contrast floor against /// `edit_bg`, or — only if the fully undimmed `accent` itself already failed the floor — - /// equals `accent` verbatim (locked decision #4: never invent a hue to force compliance). + /// equals `accent` verbatim (staged foregrounds dim toward the background, contrast-clamped: + /// never invent a hue to force compliance). fn assert_staged_foreground_contract(result: Color, accent: Color, edit_bg: Color) { let ratio = contrast_ratio(result, edit_bg).unwrap(); if ratio < STAGED_FG_LUMINANCE_FLOOR { @@ -1493,7 +1545,8 @@ mod tests { #[test] fn light_staged_foregrounds_satisfy_the_derivation_contract() { // `light()`'s pale staged edit washes push add's undimmed contrast below the floor — - // the accepted "use undimmed" fallback (locked decision #4) — while del's still clears + // the accepted "use undimmed" fallback (staged foregrounds dim toward the background, + // contrast-clamped) — while del's still clears // it via the backoff path. Both are exercised here so the contract, not a specific // numeric outcome, is what's pinned. let t = Palette::light(); @@ -1514,7 +1567,8 @@ mod tests { #[test] fn apply_overrides_base01_and_base02_rewrite_filler_fg_and_selection_bg() { - // CS11: these two slots were parsed but wired to nothing before. + // The diff foreground/background split: these two slots were parsed but wired to + // nothing before. let mut overrides = ThemeOverrides::default(); overrides.set_slot(1, Color::Rgb(0x11, 0x11, 0x11)); // base01 → filler_fg overrides.set_slot(2, Color::Rgb(0x22, 0x22, 0x22)); // base02 → selection_bg @@ -1741,7 +1795,8 @@ mod tests { #[test] fn mono_pane_header_focused_fg_collapses_with_dim_leaving_bold_the_only_differentiator() { - // CS1 (`focused-pane-header`, locked decision #3): under `NO_COLOR`, `pane_header_focused_fg` + // `focused-pane-header` (locked decision: bold is structural, applied unconditionally): + // under `NO_COLOR`, `pane_header_focused_fg` // and `dim` both collapse to `Color::Reset` — color alone can no longer tell a focused // header label from an unfocused one, so `crate::render`'s structural BOLD is load-bearing // here (asserted against real render output in `render.rs`'s own NO_COLOR test). diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index c83668d9..32bbb911 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -5,18 +5,21 @@ //! read events through the [`AppEvent`] inbox rather than calling crossterm directly from the //! loop. //! -//! ADR-037 (progressive pipeline) supersedes M4's locked decision #4 — the "no threads, no +//! ADR-037 (progressive pipeline) supersedes staging verbs' locked decision that the +//! runtime stays sync, polling the index signature on Tick — the "no threads, no //! `mpsc`" letter of that note, recorded here in an earlier revision, no longer holds. A //! dedicated *input thread* (spawned by [`Tui::run`]) is now the ONLY code that calls //! crossterm's event API: it blocks on `event::read()` forever, maps each event exactly like //! this module's old `next_event`/`drain_pending` read arms did, and forwards mapped events into //! an `std::sync::mpsc` inbox that the main loop drains via [`recv_event`]/[`drain_pending`]. //! `recv_timeout`'s timeout arm IS the `Tick` beat — unchanged from before, just relocated from -//! `event::poll`'s timeout to the channel's. The M4 index watcher's *semantics* are exactly +//! `event::poll`'s timeout to the channel's. The staging-verbs index watcher's *semantics* are +//! exactly //! unchanged by this move: it still compares [`workon_review::refresh::IndexSignature`] and //! re-diffs in place via [`App::on_tick`] on every `Tick`; only the beat's mechanism moved. //! -//! CS10 turns the mouse on: [`Tui::acquire`] enables capture for the whole session (undone by +//! Mouse support turns the mouse on: [`Tui::acquire`] enables capture for the whole session (undone +//! by //! [`Tui::restore`] and, unconditionally, the panic hook), and [`map_terminal_event`] maps a //! left-click or wheel-scroll into an [`AppEvent::Mouse`] the loop dispatches to //! [`workon_review::app::App::handle_click`]/[`workon_review::app::App::handle_wheel`] — every @@ -63,7 +66,8 @@ use workon_review::theme::{Palette, PaletteContext}; pub enum AppEvent { Key(KeyEvent), Resize(u16, u16), - /// A left-click or wheel-scroll (CS10) — the only [`MouseEventKind`]s [`map_terminal_event`] + /// A left-click or wheel-scroll (mouse support) — the only [`MouseEventKind`]s + /// [`map_terminal_event`] /// maps; drag, move, non-left buttons, and up events are dropped at the mapping step, exactly /// like key release/repeat. Mouse(MouseEvent), @@ -101,7 +105,7 @@ impl PartialEq for AppEvent { /// match on them directly. Every fully-comparable variant needs its own arm here: the /// `_ => false` catch-all exists ONLY for `FileReady`/`ChangesetReady`, and letting a /// comparable variant fall into it silently breaks reflexivity (`Mouse` did exactly that - /// when CS10 first added it — crossterm's `MouseEvent` derives `PartialEq` fine). + /// when mouse support first added it — crossterm's `MouseEvent` derives `PartialEq` fine). fn eq(&self, other: &Self) -> bool { match (self, other) { (AppEvent::Key(a), AppEvent::Key(b)) => a == b, @@ -119,7 +123,7 @@ impl PartialEq for AppEvent { type InboxMessage = io::Result; /// Map one crossterm terminal [`Event`] to the [`AppEvent`] the loop reacts to — key-press, -/// resize, and (CS10, extended by the mouse h-wheel follow-up) a left-click or vertical/ +/// resize, and (mouse support, extended by the mouse h-wheel follow-up) a left-click or vertical/ /// horizontal wheel-scroll map; key release/repeat, every other mouse kind (drag, move, non-left /// buttons, button-up), paste, and focus events are skipped (`None`). Pure and independent of any /// thread or channel, so it's unit-tested directly; the input thread's loop body is a thin wrapper @@ -379,8 +383,9 @@ struct Pipeline<'a> { } /// Receive the next event from `inbox`, waiting up to `timeout`. A timeout with nothing received -/// yields `Ok(AppEvent::Tick)` — the loop's regular redraw beat, and the mechanism the M4 index -/// watcher polls on (see the module doc). A disconnected inbox (the input thread panicked, or +/// yields `Ok(AppEvent::Tick)` — the loop's regular redraw beat, and the mechanism the +/// staging-verbs index watcher polls on (see the module doc). A disconnected inbox (the input +/// thread panicked, or /// exited after an error without this being observed yet) is surfaced as an `io::Error` rather /// than spinning — the loop must exit, not busy-loop on an empty channel forever. fn recv_event(inbox: &mpsc::Receiver, timeout: Duration) -> io::Result { @@ -439,7 +444,7 @@ enum Action { NextChangeset, PrevChangeset, ToggleLayout, - CycleZoom, + ToggleMaximize, ToggleSplitFocus, Refresh, StageHunk, @@ -473,6 +478,8 @@ enum Action { SearchFocus, SearchNext, SearchPrev, + CopyLines, + CopyLocation, None, } @@ -494,7 +501,7 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::ScrollTop => Action::ScrollTop, Command::ScrollBottom => Action::ScrollBottom, Command::ToggleLayout => Action::ToggleLayout, - Command::CycleZoom => Action::CycleZoom, + Command::ToggleMaximize => Action::ToggleMaximize, Command::ToggleSplitFocus => Action::ToggleSplitFocus, Command::Refresh => Action::Refresh, Command::StageHunk => Action::StageHunk, @@ -511,6 +518,8 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::Search => Action::SearchFocus, Command::SearchNext => Action::SearchNext, Command::SearchPrev => Action::SearchPrev, + Command::CopyLines => Action::CopyLines, + Command::CopyLocation => Action::CopyLocation, Command::NextFile => Action::NextFile, Command::PrevFile => Action::PrevFile, Command::NextHunk => Action::NextHunk, @@ -588,7 +597,7 @@ fn map_key( /// have produced — e.g. `j` then immediately `s` must stage the same hunk eager code would have. /// /// Exempt (returns `false`): every action that ends in its own fresh `open_current` (`NextFile`, -/// `PrevFile`, `NextChangeset`, `PrevChangeset`, `CycleZoom`, and the outline nav/confirm actions), +/// `PrevFile`, `NextChangeset`, `PrevChangeset`, `ToggleMaximize`, and the outline nav/confirm actions), /// since those simply set a NEW pending open rather than needing the current one force-completed; /// plus pure UI toggles/no-ops (`Refresh` rebuilds all views itself; `ToggleHelp`/`Quit`/`None` /// touch no view state at all). @@ -617,7 +626,8 @@ fn action_needs_loaded_view(action: Action) -> bool { /// Apply an [`Action`] to `app`. Returns `true` when the loop should exit. /// -/// Chokepoint (CS4): before doing anything else, force-complete a pending deferred open for every +/// Chokepoint (idle-deferred file loads): before doing anything else, force-complete a pending +/// deferred open for every /// action [`action_needs_loaded_view`] flags — see that function's doc comment for the principle /// and the exemption list. [`App::complete_pending_open`] is a no-op when nothing is pending, so /// this costs nothing outside defer mode (where `open_pending` is never set) or when the debounce @@ -640,7 +650,7 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::NextChangeset => app.next_changeset(), Action::PrevChangeset => app.prev_changeset(), Action::ToggleLayout => app.toggle_layout(), - Action::CycleZoom => app.cycle_zoom(), + Action::ToggleMaximize => app.toggle_maximize(), Action::ToggleSplitFocus => app.toggle_split_focus(), Action::Refresh => app.coordinated_refresh(), Action::StageHunk => app.stage_hunk(), @@ -659,8 +669,9 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::OutlineConfirm => app.outline_confirm(), Action::OutlineCycleMode => app.outline_cycle_mode(), // `h`/`left` pans the diff back to column 0 first (mirroring the outline's own home - // position) and only actually focuses the outline once there — see the handoff's locked - // decision #2. Implemented here rather than in `App::focus_outline` itself, since that + // position) and only actually focuses the outline once there — see the hscroll handoff's + // locked decision that `h`/`left` pans back to column 0 before focusing the outline. + // Implemented here rather than in `App::focus_outline` itself, since that // method is also called from the outline toggle (`App::toggle_outline`) and the mouse // click/wheel paths (`App::handle_click`/`handle_wheel`), none of which should gain pan // behavior. @@ -686,6 +697,8 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::SearchFocus => app.search_focus(), Action::SearchNext => app.search_next(), Action::SearchPrev => app.search_prev(), + Action::CopyLines => app.copy_lines(), + Action::CopyLocation => app.copy_location(), Action::None => {} } false @@ -720,7 +733,8 @@ fn resolve_key( app.cancel_selection(); return KeyOutcome::Handled; } - // M11 CS3 (`diff-search`): Esc with an ACCEPTED search active (the prompt itself already + // The in-diff search (`diff-search`): Esc with an ACCEPTED search active (the prompt itself + // already // closed — see [`apply_search_input_key`]'s own Esc arm for the prompt-open case) clears // it, ranked in this same tier (before the outline-focused-quit/focus-outline arms below — // see `update`'s doc comment). @@ -730,7 +744,8 @@ fn resolve_key( return KeyOutcome::Handled; } } - // CS2 (outline-filter): with the outline focused (the input row does NOT have capture — + // The outline fuzzy filter (`outline-filter`): with the outline focused (the input row does NOT + // have capture — // that's `update`'s case-3 modal arm) and a query actively narrowing the list, Esc unwinds // the filter instead of quitting — mirroring how the selection-Esc arm above unwinds the // diff's innermost mode before Esc's outer meanings apply. Only the NEXT Esc reaches the @@ -751,7 +766,7 @@ fn resolve_key( )) } -/// `update`'s case-3 modal arm: apply one key press while the CS2 outline-filter INPUT has +/// `update`'s case-3 modal arm: apply one key press while the outline fuzzy filter INPUT has /// keyboard capture (see [`App::outline_filter_focused`]). Every branch calls straight into an /// `App::outline_filter_*` method — no [`Action`]/[`map_key`] indirection, mirroring the /// confirm/help modals' own direct `key.code` matches just above this arm's call site, rather @@ -803,7 +818,7 @@ fn prompt_edit_for_key(key: KeyEvent) -> Option { } } -/// `update`'s case-3 modal arm: apply one key press while the CS2 outline-filter INPUT has +/// `update`'s case-3 modal arm: apply one key press while the outline fuzzy filter INPUT has /// keyboard capture (see [`App::outline_filter_focused`]). Handles its own Enter/Esc and the /// outline-list-navigation extras (`Ctrl-c`/`Ctrl-n`/`Ctrl-p`/`Down`/`Up`) directly, then /// delegates every other key to [`prompt_edit_for_key`] — every branch calls straight into an @@ -835,7 +850,8 @@ fn apply_filter_input_key(app: &mut App, key: KeyEvent) { } } -/// `update`'s search-prompt modal arm (M11 CS3, `diff-search`): apply one key press while the +/// `update`'s search-prompt modal arm (the in-diff search, `diff-search`): apply one key press +/// while the /// diff-view search prompt has keyboard capture (see [`App::search_focused`]). Mirrors /// [`apply_filter_input_key`]'s shape (own Enter/Esc first, then [`prompt_edit_for_key`]) but /// WITHOUT that arm's outline-list-navigation extras: the search prompt has no outline-list- @@ -863,7 +879,7 @@ fn apply_search_input_key(app: &mut App, key: KeyEvent) { /// Apply one [`AppEvent`] to `app`. Returns `true` when the loop should exit (q/Esc). Resize is a /// no-op — ratatui re-measures `body_area` every frame regardless. Tick drives -/// [`App::on_tick`], the M4 index watcher's poll (see the module doc). +/// [`App::on_tick`], the staging-verbs index watcher's poll (see the module doc). /// /// A `Key` event clears any showing footer notice BEFORE applying the key's own action, so a /// notice stays visible until the user's next keystroke — that same keystroke both dismisses the @@ -871,7 +887,7 @@ fn apply_search_input_key(app: &mut App, key: KeyEvent) { /// tick isn't the user acting on the message. /// /// Esc precedence (highest first): a pending discard confirm > the help overlay being open > the -/// CS2 outline-filter input having capture > the M11 CS3 search prompt having capture > an active +/// outline fuzzy filter input having capture > the in-diff search prompt having capture > an active /// line selection OR an active search (diff-focused) > an active outline-filter query /// (outline-focused) > the outline having focus > the diff having focus with the outline open > /// the normal key map (where Esc quits). Concretely — the home-base model: the outline is where @@ -886,7 +902,7 @@ fn apply_search_input_key(app: &mut App, key: KeyEvent) { /// reacts). Ranked just below the confirm modal — in practice the two are never up /// together, since opening help doesn't run through a confirm, but the confirm winning keeps /// a destructive prompt from ever being silently dismissed by a stray overlay key. -/// 3. Otherwise, the CS2 outline-filter INPUT (`/`, while it has capture — see +/// 3. Otherwise, the outline fuzzy filter INPUT (`/`, while it has capture — see /// [`App::outline_filter_focused`]) captures next, mirroring the same swallow: typing/editing /// keys reach [`crate::prompt::PromptState`], `Enter`/`Esc` return capture to the outline row /// list KEEPING the query, `Ctrl-c` clears it and returns capture too, and `Down`/`Up`/ @@ -894,7 +910,7 @@ fn apply_search_input_key(app: &mut App, key: KeyEvent) { /// (opening help while filtering isn't reachable today — `?` isn't part of the input's own key /// set — but the ordering still says which would win if that ever changed) and above every /// other case, since none of them should observe a key the filter input itself consumes. -/// 4. Otherwise, the M11 CS3 search prompt (`/` in the diff view, while it has capture — see +/// 4. Otherwise, the in-diff search prompt (`/` in the diff view, while it has capture — see /// [`App::search_focused`]) captures next, mirroring the outline-filter input's swallow: /// typing/editing keys reach [`crate::prompt::PromptState`] (live-previewing highlights, never /// moving the cursor), `Enter` accepts and jumps, `Esc` aborts back to whatever search (or @@ -950,9 +966,9 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap KeyOutcome::Handled => false, KeyOutcome::Action(action) => apply_action(app, action), }, - // CS10: all four modals swallow mouse input exactly like they swallow keys (cases 1-4 - // above) — a click/wheel while a discard confirm, the help overlay, the CS2 outline-filter - // input, or the M11 CS3 search prompt is up does nothing. + // Mouse support: all four modals swallow mouse input exactly like they swallow keys (cases + // 1-4 above) — a click/wheel while a discard confirm, the help overlay, the outline fuzzy + // filter input, or the in-diff search prompt is up does nothing. AppEvent::Mouse(_) if app.pending_confirm.is_some() || app.help_visible @@ -1020,7 +1036,8 @@ enum RunKind { /// calls would (see [`update_batch`]'s doc comment for why this only holds for same-sign runs). /// /// `MoveCursorBy` reads cursor-space state exactly like [`apply_action`]'s `Action::MoveCursorBy` -/// arm does, and this is the OTHER path (besides `apply_action`) that can run it — CS2's +/// arm does, and this is the OTHER path (besides `apply_action`) that can run it — the +/// outline fuzzy filter's /// coalescing calls `App::move_cursor_by` directly rather than routing the flush through /// `apply_action`, so the same force-completion has to happen here too (see the plan's chokepoint /// note: whichever path applies `MoveCursorBy` must complete first). `OutlineMoveBy` needs no such @@ -1071,7 +1088,8 @@ fn update_batch( for event in events { match event { - // The coalescable path: no modal is up (CS2's outline-filter input and the M11 CS3 + // The coalescable path: no modal is up (the outline fuzzy filter input and the + // in-diff search // search prompt both included — a key while either has capture must reach // `apply_filter_input_key`/`apply_search_input_key` via the catch-all arm's `update` // delegation below, never `resolve_key`/the coalescing path), and this isn't the @@ -1163,7 +1181,8 @@ fn install_panic_hook() { std::panic::set_hook(Box::new(move |info| { let _ = disable_raw_mode(); let mut out = terminal_writer(); - // CS10: disable mouse capture unconditionally, same as `Tui::restore` — a stray disable + // Mouse support: disable mouse capture unconditionally, same as `Tui::restore` — a stray + // disable // sequence when capture was never enabled (a panic before `Tui::acquire` reaches its own // `EnableMouseCapture`) is harmless, and there's no cheaper way from here to know whether // capture is currently on. @@ -1192,7 +1211,8 @@ impl Tui { install_panic_hook(); enable_raw_mode()?; let mut out = terminal_writer(); - // CS10: capture the mouse for the whole session — `map_terminal_event` only ever lets a + // Mouse support: capture the mouse for the whole session — `map_terminal_event` only ever + // lets a // left-click or wheel-scroll through, so this doesn't cost the terminal's normal // text-selection UX beyond what most terminals' shift-click bypass already covers. execute!(out, EnterAlternateScreen, EnableMouseCapture)?; @@ -1214,9 +1234,11 @@ impl Tui { } /// Run the main loop against `app`, then restore the terminal. Callers must have already - /// called `app.open_current()` — under CS4's deferred-load mode (`app.set_defer_loads(true)`, + /// called `app.open_current()` — under idle-deferred file loads' deferred-load mode + /// (`app.set_defer_loads(true)`, /// `main.rs`'s default) that call marks the open PENDING rather than loading eagerly, so the - /// first frame shows CS4's placeholder until the ADR-037 loader thread answers (or a + /// first frame shows idle-deferred file loads' placeholder until the ADR-037 loader thread + /// answers (or a /// force-completion chokepoint loads it synchronously first); a caller that never turned defer /// mode on gets eager behavior. /// @@ -1327,9 +1349,9 @@ impl Tui { } self.restored = true; disable_raw_mode()?; - // CS10: disable mouse capture before leaving the alternate screen — same ordering - // convention as the raw-mode/alternate-screen pair, undone in the reverse order acquire - // set them up in. + // Mouse support: disable mouse capture before leaving the alternate screen — same + // ordering convention as the raw-mode/alternate-screen pair, undone in the reverse + // order acquire set them up in. execute!( self.terminal.backend_mut(), DisableMouseCapture, @@ -1355,7 +1377,8 @@ fn draw_splash(frame: &mut Frame<'_>, msg: &str) { frame.render_widget(para, frame.area()); } -/// CS4's input-idle window: how long the loop waits with no new input before running a pending +/// Idle-deferred file loads' input-idle window: how long the loop waits with no new input before +/// running a pending /// deferred file open. Long enough that held-key autorepeat (~30-90ms between events on most /// terminals) usually keeps re-arming the debounce and deferring the load past the whole burst; /// short enough that releasing the key feels instant rather than laggy. Tunable if either edge @@ -1389,7 +1412,8 @@ fn event_loop( // While an open is pending, wait on the short debounce window instead of the regular // 200ms redraw beat, so the deferred load's request goes out promptly once input goes // quiet — a plain timeout (no new inbox message) is what "quiet" means here. This borrows - // the same `Tick` beat the M4 index watcher already polls on (see the module doc); the + // the same `Tick` beat the staging-verbs index watcher already polls on (see the module + // doc); the // watcher occasionally running ~120ms early during a debounce window is harmless (its own // doc comment already tolerates an "unseen" signature settling one tick late). let timeout = if app.open_pending() { @@ -1450,12 +1474,10 @@ fn event_loop( let view_warnings = app.reload_view_config(&runtime.view_config); let mut extra_warnings = runtime.warnings; extra_warnings.extend(view_warnings); - // `crate::plumb_zoom_hint_and_warnings` re-plumbs the "cycle zoom" refusal hint the - // same way `main.rs`'s `seat_app` does at startup — a reload that rebinds - // `cycle-zoom` would otherwise leave the hint naming the old key (no binding at all - // leaves the previous label in place, same as startup) — and surfaces any warnings. - // A reload with nothing to warn about still owes the user a signal that it worked. - if !crate::plumb_zoom_hint_and_warnings(app, keymap, extra_warnings) { + // `crate::surface_warnings` merges the keymap/view-config/theme-override warnings the + // same way `main.rs`'s `seat_app` does at startup and shows them as a notice. A + // reload with nothing to warn about still owes the user a signal that it worked. + if !crate::surface_warnings(app, keymap, extra_warnings) { app.notify("config reloaded", Severity::Info); } } @@ -1523,11 +1545,12 @@ mod tests { assert_eq!(map_terminal_event(Event::FocusLost), None); } - /// CS10: `map_terminal_event` maps ONLY a left-click-down or a wheel-scroll to + /// Mouse support: `map_terminal_event` maps ONLY a left-click-down or a wheel-scroll to /// `AppEvent::Mouse`; every other mouse kind — drag, move, button-up, and non-left buttons — - /// is still dropped, exactly like the pre-CS10 version dropped every mouse event outright. + /// is still dropped, exactly like the pre-mouse-support version dropped every mouse event + /// outright. /// This supersedes the old `map_terminal_event_skips_release_repeat_mouse_paste_and_focus` - /// pin (split above into the non-mouse skip cases, which are unchanged by CS10). + /// pin (split above into the non-mouse skip cases, which are unchanged by mouse support). #[test] fn map_terminal_event_maps_left_down_and_scroll_but_drops_other_mouse_kinds() { let left_down = mouse(MouseEventKind::Down(MouseButton::Left)); @@ -1733,7 +1756,8 @@ mod tests { #[test] fn g_and_shift_g_map_to_outline_top_and_bottom_when_outline_focused() { - // CS2: `g`/`G` are bound per-view (`scroll-top`/`scroll-bottom` in both View::Diff and + // The configurable-per-view-keymaps work: `g`/`G` are bound per-view + // (`scroll-top`/`scroll-bottom` in both View::Diff and // View::Outline), so the SAME key must resolve to a different Action depending on which // pane has focus — outline-focused maps to the outline jump, not the diff scroll. let km = Keymap::defaults(); @@ -1760,7 +1784,8 @@ mod tests { #[test] fn enter_and_shift_e_map_to_expand_gap_in_diff_context() { - // CS8: `enter`/`E` are bound in View::Diff only (`expand-gap`/`expand-gap-all`) — Enter + // Progressive gap expansion: `enter`/`E` are bound in View::Diff only + // (`expand-gap`/`expand-gap-all`) — Enter // stays `OutlineConfirm` when the outline has focus (see the next test). let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); @@ -1800,15 +1825,15 @@ mod tests { } #[test] - fn shift_z_and_w_map_to_zoom_and_split_focus() { - // diff-fold-keys: `cycle-zoom` moved off bare `z` to `Z` — `z` now anchors the `zM`/`zR` - // gap fold-all chords in this view (see `z_m_and_z_r_map_to_reset_and_expand_all_gaps` + fn shift_z_and_w_map_to_toggle_maximize_and_split_focus() { + // diff-fold-keys: `toggle-maximize` moved off bare `z` to `Z` — `z` now anchors the + // `zM`/`zR` gap fold-all chords in this view (see `z_m_and_z_r_map_to_reset_and_expand_all_gaps` // below), and a bare-key binding can't coexist with a longer chord sharing its prefix. let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); assert_eq!( map_key(&km, &mut pending, key(KeyCode::Char('Z')), 20, false, false), - Action::CycleZoom + Action::ToggleMaximize ); assert_eq!( map_key(&km, &mut pending, key(KeyCode::Char('w')), 20, false, false), @@ -2116,7 +2141,8 @@ mod tests { let mut pending: Vec = Vec::new(); // A plain Tick with nothing changed externally must be a safe no-op wired all the way - // through `update` — the smoke test for M4's index-watcher hookup (the substantive + // through `update` — the smoke test for the staging-verbs index-watcher hookup (the + // substantive // signature-change/echo-suppression assertions live in `app.rs`'s own `on_tick` tests, // which have direct access to its private state). let quit = update(&mut app, &km, &mut pending, AppEvent::Tick); @@ -2264,7 +2290,8 @@ mod tests { #[test] fn esc_ladder_search_prompt_then_active_search_then_outline_focus() { - // M11 CS3 (`diff-search`): three Esc presses in sequence, each landing on the NEXT lower + // The in-diff search (`diff-search`): three Esc presses in sequence, each landing on the + // NEXT lower // tier of the ladder once the higher one no longer applies — the prompt-open case first // (Esc aborts the EDIT, keeping the accepted query and its highlights), then the // accepted-search-active case (Esc clears the search entirely), then the ordinary @@ -2396,7 +2423,8 @@ mod tests { repo.assert(predicate::repo::workdir_file_equals("a.txt", "one\ntwo\n")); } - /// CS10: a pending discard confirm swallows a mouse event exactly like it swallows a key — + /// Mouse support: a pending discard confirm swallows a mouse event exactly like it swallows a + /// key — /// mirrors `pending_confirm_captures_y_and_n_and_ignores_other_keys` above. A click inside a /// live hit region must not move the cursor or resolve the confirm. #[test] @@ -2440,9 +2468,10 @@ mod tests { ); } - // ── M5 CS3: outline pane key routing ───────────────────────────────────── + // ── The outline side pane (flat and stack modes): outline pane key routing ─────────── - /// A two-committed-changeset stack, built the same way as `app.rs`/`render.rs`'s own M5 + /// A two-committed-changeset stack, built the same way as `app.rs`/`render.rs`'s own + /// outline-side-pane /// tests — `tui.rs` needs its own copy since it compiles into the separate bin crate (see /// `app_from_fixture`'s doc comment above for why the helpers can't be shared directly). fn two_committed_changesets_app(fixture: &git_workon_fixture::fixture::Fixture) -> App { @@ -2782,7 +2811,8 @@ mod tests { .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); - // CS3: pin BaseFirst explicitly — this test exercises Enter's File-row jump + focus + // The outline side pane (flat and stack modes): pin BaseFirst explicitly — this test + // exercises Enter's File-row jump + focus // return, which is orthogonal to display order, but the row offset below assumes the // base->head row layout. app.set_outline_order(workon_review::outline::OutlineOrder::BaseFirst); @@ -2790,8 +2820,8 @@ mod tests { app.toggle_outline(); // open + focus, cursor synced onto cs-b's file row assert!(app.outline_focused()); // Move the outline cursor onto cs-a's FILE row (rows, BaseFirst: [Header a, File a.txt, - // Header b, File b.txt] — cursor starts at 3; -2 lands on File a.txt at row 1). CS5 - // (`outline-fold`) removed Enter's old header-jump behavior — see + // Header b, File b.txt] — cursor starts at 3; -2 lands on File a.txt at row 1). + // `outline-fold` removed Enter's old header-jump behavior — see // `enter_on_a_header_row_toggles_fold_and_keeps_focus` below for that case — so this // keybinding-dispatch test needs a File row to still exercise a real jump+unfocus. app.outline_move_by(-2); @@ -2826,7 +2856,7 @@ mod tests { #[test] fn enter_on_a_header_row_toggles_fold_and_keeps_focus() { - // CS5 (`outline-fold`): Enter on a Header/Dir row no longer jumps+unfocuses — it toggles + // `outline-fold`: Enter on a Header/Dir row no longer jumps+unfocuses — it toggles // that row's fold and deliberately keeps focus. This is the header-row counterpart to // `enter_confirms_an_outline_jump_and_returns_focus_to_the_diff` above, verified through // the same real keybinding-dispatch path (`update`/`map_key`), not a direct @@ -2861,13 +2891,13 @@ mod tests { assert_eq!( app.current_cs(), before_cs, - "Enter on a header must NOT jump the diff (CS5)" + "Enter on a header must NOT jump the diff (outline-fold)" ); assert_eq!(app.current, before_file); assert!( app.outline_focused(), - "Enter on a header toggles its fold and keeps focus (CS5), rather than confirming a \ - jump" + "Enter on a header toggles its fold and keeps focus (outline-fold), rather than \ + confirming a jump" ); assert!( app.outline_items().len() < rows_before, @@ -2875,7 +2905,7 @@ mod tests { ); } - // ── CS3: help overlay ─────────────────────────────────────────────────── + // ── The help footer and `?` overlay ───────────────────────────────────── #[test] fn question_mark_opens_the_help_overlay() { @@ -3003,11 +3033,12 @@ mod tests { ); } - // ── CS2: coalesce buffered nav input ───────────────────────────────────── + // ── Coalescing buffered navigation input ──────────────────────────────── /// A single committed changeset with `n` distinct multi-line files ("f0.txt".."f{n-1}.txt"), - /// opened on file 0 — CS2's batching tests need several files so a coalesced outline jump has - /// intermediate rows to skip over, and several lines per file so a coalesced diff-cursor run + /// opened on file 0 — the coalescing-buffered-navigation-input tests need several files so a + /// coalesced outline jump has intermediate rows to skip over, and several lines per file so a + /// coalesced diff-cursor run /// has room to move without immediately clamping. fn many_files_app(fixture: &git_workon_fixture::fixture::Fixture, n: usize) -> App { use git2::Repository; @@ -3060,7 +3091,7 @@ mod tests { app.toggle_outline(); // open + focus, cursor synced onto file 0's row (index 0 in Flat mode) assert!(app.outline_focused()); assert!( - app.role_view_ref(0, Role::Combined).is_some(), + app.role_view_ref(0, Role::Whole).is_some(), "file 0 loaded by open_current" ); @@ -3086,12 +3117,12 @@ mod tests { assert_eq!(app.current, 4, "the diff jumps to the landing file only"); for skipped in 1..4 { assert!( - app.role_view_ref(skipped, Role::Combined).is_none(), + app.role_view_ref(skipped, Role::Whole).is_none(), "file {skipped} must never have been visited, so its view must not be loaded" ); } assert!( - app.role_view_ref(4, Role::Combined).is_some(), + app.role_view_ref(4, Role::Whole).is_some(), "the landing file's view IS loaded" ); } @@ -3367,7 +3398,7 @@ mod tests { assert_eq!(app.current, 1, "]f must have fired NextFile"); } - // ── CS4: idle-deferred loads ────────────────────────────────────────────── + // ── Idle-deferred file loads ──────────────────────────────────────────────── #[test] fn deferred_outline_burst_loads_nothing_until_completed() { @@ -3381,7 +3412,8 @@ mod tests { .unwrap(); let mut app = many_files_app(&fixture, 5); // `many_files_app` opens eagerly (defer mode isn't on yet) — file 0 is loaded before we - // flip the switch, exactly like a real session's startup open would be under CS4 (see + // flip the switch, exactly like a real session's startup open would be under + // idle-deferred file loads (see // `main.rs`, which turns defer mode on before its own initial `open_current`). app.set_defer_loads(true); app.set_outline_mode(OutlineMode::Flat); @@ -3406,7 +3438,7 @@ mod tests { ); for f in 1..=4 { assert!( - app.role_view_ref(f, Role::Combined).is_none(), + app.role_view_ref(f, Role::Whole).is_none(), "file {f} must not be loaded — not even the landing file, until completed" ); } @@ -3415,7 +3447,7 @@ mod tests { assert!(!app.open_pending()); assert!( - app.role_view_ref(4, Role::Combined).is_some(), + app.role_view_ref(4, Role::Whole).is_some(), "completing the pending open loads only the landing file" ); } @@ -3497,7 +3529,7 @@ mod tests { repo_eager.assert(predicate::repo::has_staged_file("a.txt")); } - // ── CS5: launch splash ──────────────────────────────────────────────────── + // ── The launch splash and early terminal takeover ────────────────────────── #[test] fn splash_renders_the_message() { @@ -3735,7 +3767,8 @@ mod tests { // ── diff-hscroll: `Action::FocusOutline` pans home before focusing ───────────── - /// Locked decision #2: `h`/`left` (`Action::FocusOutline`) pans the diff back toward column + /// The hscroll handoff's locked decision that `h`/`left` pans back to column 0 before + /// focusing the outline: `h`/`left` (`Action::FocusOutline`) pans the diff back toward column /// `0` first while panned, and only actually focuses the outline once there — implemented in /// this dispatch arm rather than in `App::focus_outline` itself (see that arm's comment), so /// this is only testable at the `apply_action` layer, not through `App` alone. @@ -3819,11 +3852,12 @@ mod tests { ); } - // ── CS2 (`outline-filter`, M11): the filter-input modal cascade arm ────────── + // ── The outline fuzzy filter (`outline-filter`): the filter-input modal cascade arm ──── /// A single (uncommitted) changeset with three distinct files, outline open+focused in Flat - /// mode (no header row in the way) — CS2's cascade tests just need "type `/`, then some keys, - /// assert `App` state," and Flat mode keeps the row math simple (every row is a `File`). + /// mode (no header row in the way) — the outline fuzzy filter's cascade tests just need + /// "type `/`, then some keys, assert `App` state," and Flat mode keeps the row math simple + /// (every row is a `File`). fn filter_test_app() -> App { use git_workon_fixture::prelude::*; use workon_review::outline::OutlineMode; diff --git a/git-workon-review/tests/pty/pty_responsiveness.rs b/git-workon-review/tests/pty/pty_responsiveness.rs index f8a3d5c9..12f39d57 100644 --- a/git-workon-review/tests/pty/pty_responsiveness.rs +++ b/git-workon-review/tests/pty/pty_responsiveness.rs @@ -126,8 +126,8 @@ fn launch_reaches_the_tui_and_quits_promptly() { // Theme pinned to dark so the `theme = auto` probe (and its deadline) stays out of this // bound — the probe's own responsiveness is pty_smoke.rs's job. One unstaged change so the // TUI actually opens; a plain (non-Graphite) repo keeps behavior identical whether or not - // the machine has `gt` on PATH — and `StackModel::detect` still runs `detect_gt` first, so - // a reintroduced subprocess spawn there is still inside the measured window. + // the machine has `gt` on PATH. `StackModel::detect` no longer probes for `gt` at all, so + // this bound no longer covers that lookup — it stays a pure metadata check. let fixture = FixtureBuilder::new() .config("workon.review.theme", "dark") .unstaged_file("file.txt", "a\nb\nc\n", "a\nCHANGED\nc\n") diff --git a/git-workon-review/tests/suite/apply.rs b/git-workon-review/tests/suite/apply.rs index 91dba077..7b76ab82 100644 --- a/git-workon-review/tests/suite/apply.rs +++ b/git-workon-review/tests/suite/apply.rs @@ -1,5 +1,6 @@ //! Whole-hunk apply round-trips, run against BOTH `Git2Applier` and `CliApplier` via -//! `for_each_applier` (plan trap 6: CLI is the oracle, git2 is re-verified against it). Each +//! `for_each_applier` (git2-vs-CLI round-trip verdict: CLI is the oracle, git2 is re-verified +//! against it). Each //! test builds a FRESH fixture per applier — appliers mutate live repository state, so sharing //! one fixture across both runs would let the second applier's assertions depend on the //! first's side effects. diff --git a/git-workon-review/tests/suite/cli.rs b/git-workon-review/tests/suite/cli.rs index 71813da7..d3239667 100644 --- a/git-workon-review/tests/suite/cli.rs +++ b/git-workon-review/tests/suite/cli.rs @@ -1,7 +1,8 @@ use assert_cmd::cargo_bin_cmd; use git_workon_fixture::prelude::*; -/// Locked design decision #7 (M3 plan): a clean worktree prints "nothing to review" to stderr +/// Locked design decision (the initial-renderer plan): a clean worktree prints "nothing to review" +/// to stderr /// and exits 0 without ever entering the TUI — no raw-mode/alternate-screen setup, so this stays /// a plain `assert_cmd` invocation (no PTY needed). #[test] @@ -46,9 +47,10 @@ fn bash_candidates(cwd: &std::path::Path, word: &str) -> Vec { .collect() } -/// ADR-036 "Completion" section (CS5): the `stack`/`uncommitted` keywords plus local branch and -/// tag names, offline, via git2 ref enumeration — this is the SOURCE positional's dynamic -/// completer, exercised through the same `COMPLETE=bash` protocol M6 wired the binary to answer. +/// ADR-036 "Completion" section (source completion and sub-delegation): the +/// `stack`/`uncommitted` keywords plus local branch and tag names, offline, via git2 ref +/// enumeration — this is the SOURCE positional's dynamic completer, exercised through the same +/// `COMPLETE=bash` protocol the CLI-integration work wired the binary to answer. #[test] fn source_completion_offers_keywords_and_local_refs() { let fixture = FixtureBuilder::new() @@ -100,7 +102,8 @@ fn source_completion_completes_range_rhs_with_lhs_prefix() { } /// The binary answers the `COMPLETE=` dynamic-completion protocol (clap_complete's -/// `CompleteEnv`), so git-workon can delegate `git workon review ` completion to it (M6 CS3). +/// `CompleteEnv`), so git-workon can delegate `git workon review ` completion to it +/// (external-subcommand completion enumeration). /// A non-repo cwd degrades to keyword-only candidates (ADR-036: any git error → keywords only, /// never a completion-path error) rather than failing repo discovery — the load-bearing contract /// is that `COMPLETE` mode short-circuits into the completer *before* that discovery even runs. diff --git a/git-workon-review/tests/suite/diff_model.rs b/git-workon-review/tests/suite/diff_model.rs index 25c9bcf6..d0c5a56f 100644 --- a/git-workon-review/tests/suite/diff_model.rs +++ b/git-workon-review/tests/suite/diff_model.rs @@ -1,7 +1,8 @@ //! Model-shape and byte-fidelity tests for `workon_review::model`/`workon_review::acquire`. //! //! The EOFNL characterization test pins what git2 0.21 actually emits for a no-trailing-newline -//! file (plan risk #2) — this is normative for CS2/CS3's patch synthesis, not just a sanity +//! file (plan risk #2) — this is normative for whole-hunk patch synthesis and the patch-apply +//! chokepoint, not just a sanity //! check. Fixtures used for byte assertions pin `core.autocrlf=false` so bytes are //! platform-stable (plan risk #6). @@ -171,7 +172,8 @@ fn binary_file_has_no_hunks() -> Result<(), Box> { Ok(()) } -// ── EOFNL characterization (plan risk #2 — normative for CS2/CS3) ──────────── +// ── EOFNL characterization (plan risk #2 — normative for whole-hunk patch synthesis and the +// patch-apply chokepoint) ───────────────────────────────────────────────────────────────── /// Pins git2 0.21's actual EOFNL behavior for a file with no trailing newline whose middle /// line changes: git2 emits the trailing context line WITHOUT its newline, immediately @@ -343,10 +345,11 @@ fn hunk_to_diff_bytes_matches_diff_print() -> Result<(), Box Result<(), Box> { +fn partially_staged_file_appears_fused_in_whole() -> Result<(), Box> { let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .partially_staged_file( @@ -363,9 +366,9 @@ fn partially_staged_file_appears_fused_in_combined() -> Result<(), Box Result<(), Box Result<(), Box> { +fn untracked_file_appears_as_added_in_whole() -> Result<(), Box> { let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .untracked_file("new.txt", "hello\nworld\n") @@ -391,8 +394,8 @@ fn untracked_file_appears_as_added_in_combined() -> Result<(), Box Result<(), Box Result<(), Box> { +fn renamed_in_worktree_file_surfaces_as_renamed_in_whole() -> Result<(), Box> +{ let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") // Deleted from the working tree, still in HEAD/index... @@ -420,8 +423,8 @@ fn renamed_in_worktree_file_surfaces_as_renamed_in_combined( let repo = fixture.repo()?; let diffs = diff_uncommitted(repo)?; - assert_eq!(diffs.combined.files.len(), 1); - let file = &diffs.combined.files[0]; + assert_eq!(diffs.whole.files.len(), 1); + let file = &diffs.whole.files[0]; assert_eq!(file.status, FileStatus::Renamed); assert_eq!(file.path, "new.txt"); assert_eq!(file.old_path.as_deref(), Some("old.txt")); diff --git a/git-workon-review/tests/suite/file_ops.rs b/git-workon-review/tests/suite/file_ops.rs index bc1aab45..a478887c 100644 --- a/git-workon-review/tests/suite/file_ops.rs +++ b/git-workon-review/tests/suite/file_ops.rs @@ -1,4 +1,4 @@ -//! Trap 3 (whole-file ops): tripwires proving the naive hunk-patch shapes for +//! Whole-file-ops fallback: tripwires proving the naive hunk-patch shapes for //! deletion/untracked files misbehave (empty-blob-stage / rejection), then the routed //! `ops.rs`/`file_ops.rs` behavior that exists to route around them. //! @@ -70,7 +70,8 @@ fn naive_deletion_hunk_patch(path: &str, committed_content: &str) -> PatchText { /// TRIPWIRE: a naive whole-hunk stage of a deletion (deleting every line, but keeping the /// `a/`/`b/` paths as if the file still existed) is ACCEPTED by `git apply --cached` — it /// stages an EMPTY BLOB for the path instead of removing the index entry. This is exactly the -/// bug `ops.rs`'s routing to `file_ops::stage_file` exists to prevent (trap 3). Verified +/// bug `ops.rs`'s routing to `file_ops::stage_file` exists to prevent (the whole-file-ops +/// fallback). Verified /// directly against `CliApplier` (the oracle), bypassing `ops.rs`/`synthesis.rs` entirely, /// since `whole_hunk_patch` already refuses `FileStatus::Deleted` and can't produce this patch /// itself. diff --git a/git-workon-review/tests/suite/line_synthesis.rs b/git-workon-review/tests/suite/line_synthesis.rs index 9da208e1..88b85c36 100644 --- a/git-workon-review/tests/suite/line_synthesis.rs +++ b/git-workon-review/tests/suite/line_synthesis.rs @@ -1,5 +1,5 @@ -//! Line-precise patch synthesis round-trips (trap 1: direction-dependent drop rules; trap 2: -//! the EOFNL del-to-context splice), run against both appliers via `for_each_applier` — see +//! Line-precise patch synthesis round-trips (direction-dependent drop rules; the no-newline-at- +//! EOF splice), run against both appliers via `for_each_applier` — see //! `tests/apply.rs` for the pattern this borrows (fresh fixture per applier backend). use git_workon_fixture::prelude::*; @@ -183,11 +183,11 @@ fn base_old_partial_patch_fails_under_reverse_apply() { ); } -/// Fixture for the trap-2 (EOFNL splice) tests: the committed file's last line ("last") has NO +/// Fixture for the no-newline-at-EOF-splice tests: the committed file's last line ("last") has NO /// trailing newline; the modification deletes that line and adds two new ones, the last of /// which ("more\n") DOES end in a newline (so the file gains a trailing newline overall). This /// is the shape that produces a deletion carrying `missing_newline` with kept lines after it — -/// trap 2's precondition. +/// the no-newline-at-EOF splice's precondition. fn eofnl_fixture() -> FixtureBuilder<'static> { FixtureBuilder::new() .config("core.autocrlf", "false") @@ -274,7 +274,8 @@ fn spliced_eofnl_patch_stages_correct_bytes() { let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); let file = &diffs.unstaged.files[0]; // Keep only "more\n"; drop the "last" deletion (context, missing_newline) and the - // "replaced\n" addition (omitted under base=Old) — trap 2's exact precondition. + // "replaced\n" addition (omitted under base=Old) — the no-newline-at-EOF splice's exact + // precondition. let keep_add = line_index(file, 0, LineKind::Addition, "more\n"); let sel = LineSelection { keep_adds: [keep_add].into(), diff --git a/git-workon-review/tests/suite/roundtrip_corpus.rs b/git-workon-review/tests/suite/roundtrip_corpus.rs index 0e28924b..9f15f2fe 100644 --- a/git-workon-review/tests/suite/roundtrip_corpus.rs +++ b/git-workon-review/tests/suite/roundtrip_corpus.rs @@ -1,4 +1,5 @@ -//! The round-trip verdict corpus (trap 6): every write-path scenario from M2's trap corpus, +//! The round-trip verdict corpus (the git2-vs-CLI round-trip verdict): every write-path scenario +//! from the diff-model-and-patch-synthesis trap corpus, //! driven through the `ops.rs` entry points and run against BOTH backends — //! [`workon_review::apply::CliApplier`] (the oracle) and [`workon_review::apply::Git2Applier`] //! (the backend under verification). @@ -400,7 +401,7 @@ fn partial_stage_dels_only_verify(fixture: &Fixture) { } /// Two separate changes ("old2"->"new2", "old4"->"new4") in one hunk, separated by a context -/// line — the shape the direction rules (trap 1) need: keeping one change and dropping the +/// line — the shape the direction-dependent drop rules need: keeping one change and dropping the /// other must not treat the dropped one uniformly across stage/unstage/discard. const TWO_CHANGE_COMMITTED: &str = "line1\nold2\nline3\nold4\nline5\n"; const TWO_CHANGE_MODIFIED: &str = "line1\nnew2\nline3\nnew4\nline5\n"; @@ -1353,8 +1354,9 @@ fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { /// swallowed by loosening this test. const KNOWN_DIVERGENCES: &[&str] = &[]; -/// The VERDICT: renders the git2-vs-CLI comparison for `docs/rfc/workon-review.md`'s "M2 -/// verdict" section. Collects divergences instead of panicking per-scenario so the full set is +/// The VERDICT: renders the git2-vs-CLI comparison for `docs/rfc/workon-review.md`'s "git2 vs CLI +/// apply verdict" section. Collects divergences instead of panicking per-scenario so the full set +/// is /// visible in one run. #[test] fn corpus_against_git2() { diff --git a/git-workon-review/tests/suite/source.rs b/git-workon-review/tests/suite/source.rs index 46e8e303..bff3bcc9 100644 --- a/git-workon-review/tests/suite/source.rs +++ b/git-workon-review/tests/suite/source.rs @@ -1,5 +1,5 @@ -//! Fixture tests for the M7 `Source` classifier + resolver (ADR-036): the `stack`/`uncommitted` -//! keywords (CS2), and `` shape-aware dispatch + `Range` resolution (CS3). Output +//! Fixture tests for the source selector's `Source` classifier + resolver (ADR-036): the +//! `stack`/`uncommitted` keywords, and `` shape-aware dispatch + `Range` resolution. Output //! assertions pin `NO_COLOR=1` per the FORCE_COLOR trap this dev environment sets. use assert_cmd::cargo_bin_cmd; @@ -128,9 +128,11 @@ fn stack_keyword_caught_up_and_clean_prints_nothing_to_review() { .stderr(predicate::str::contains("nothing to review")); } -/// The classifier/resolver seam CS2 introduces resolves `Ref` to a named, hinted pre-TUI -/// failure (real ref resolution is CS3) — end-to-end through the binary, so this doubles as -/// the CS2 manual smoke check ("a source shape renders or errors honestly"). Color is pinned +/// The classifier/resolver seam the stack/uncommitted-source-keywords work introduces resolves +/// `Ref` to a named, hinted pre-TUI failure (real ref resolution is `` and range +/// resolution) — end-to-end through the binary, so this doubles as +/// the stack/uncommitted-source-keywords manual smoke check ("a source shape renders or errors +/// honestly"). Color is pinned /// off: `FORCE_COLOR=3` is set in this dev environment and would otherwise leak ANSI codes /// into the assertion. #[test] @@ -150,7 +152,7 @@ fn unresolvable_ref_source_prints_named_error_and_exits_nonzero() { )); } -// ── CS3: `` shape-aware dispatch + `Range` resolution ────────────────────────────────── +// ── `` and range resolution: shape-aware dispatch + `Range` resolution ───────────────── both_formats!(ref_on_graphite_tracked_branch_that_is_head_matches_auto_detect,); @@ -445,7 +447,8 @@ fn range_empty_side_defaults_to_head() -> Result<(), Box> { } /// `review ..` is a valid-but-empty range: exit 0, "nothing to review" naming -/// the source text (ADR-036's empty-but-valid UX, extended in CS3 to name the source). +/// the source text (ADR-036's empty-but-valid UX, extended by `` and range resolution to +/// name the source). #[test] fn empty_range_between_same_tag_prints_named_nothing_to_review_and_exits_zero() { let fixture = FixtureBuilder::new().build().unwrap(); diff --git a/git-workon/src/completers.rs b/git-workon/src/completers.rs index 74150c88..470d0335 100644 --- a/git-workon/src/completers.rs +++ b/git-workon/src/completers.rs @@ -106,7 +106,8 @@ fn augment_external_subcommands(cmd: Command) -> Command { const DELEGATED_EXTERNALS: &[&str] = &["review"]; /// Sub-delegate `git-workon ` completion to `git-workon-`'s own -/// `COMPLETE=` responder — the M6 CS3-deferred seam, wired here per ADR-036 CS5. +/// `COMPLETE=` responder — the external-subcommand-completion-enumeration-deferred seam, +/// wired here per ADR-036's source completion and sub-delegation. /// /// `augment_external_subcommands` (above) only adds a bare stub `Command` for each PATH-discovered /// external, with no argument definitions of its own — clap_complete's engine has no notion that diff --git a/git-workon/src/main.rs b/git-workon/src/main.rs index c7b2e12f..0a14c317 100644 --- a/git-workon/src/main.rs +++ b/git-workon/src/main.rs @@ -21,7 +21,8 @@ fn main() -> Result<()> { // Must run before `CompleteEnv`'s own dispatch: an external subcommand's stub `Command` (see // `completers::augment`) carries no argument definitions, so `CompleteEnv` alone can't // complete anything typed after it. This hands those words off to the external's own - // `COMPLETE=` responder instead (M6 CS3's deferred seam; ADR-036 CS5), and is a no-op in + // `COMPLETE=` responder instead (the external-subcommand-completion-enumeration-deferred + // seam; ADR-036's source completion and sub-delegation), and is a no-op in // every other case (see its doc comment). completers::try_delegate_external_completion(); CompleteEnv::with_factory(|| completers::augment(Cli::command())).complete(); diff --git a/git-workon/tests/suite/completions.rs b/git-workon/tests/suite/completions.rs index 2dc12608..fff374df 100644 --- a/git-workon/tests/suite/completions.rs +++ b/git-workon/tests/suite/completions.rs @@ -132,7 +132,8 @@ fn review_binary_path() -> std::path::PathBuf { .join("git-workon-review") } -/// ADR-036 CS5 / M6 CS3's deferred seam: `git workon review ` shells out to the review +/// ADR-036's source completion and sub-delegation / the external-subcommand-completion- +/// enumeration-deferred seam: `git workon review ` shells out to the review /// binary's own `COMPLETE=` responder rather than completing against review's argument-less stub /// `Command` (`augment_external_subcommands`). Uses the *real* compiled `git-workon-review` /// (via `PathStub::command_exe`, not the canned `arg:`/`cwd:` script `command` writes) so the diff --git a/git-workon/tests/suite/dispatch.rs b/git-workon/tests/suite/dispatch.rs index 9f8ee4c2..4dbd254c 100644 --- a/git-workon/tests/suite/dispatch.rs +++ b/git-workon/tests/suite/dispatch.rs @@ -122,7 +122,8 @@ fn external_shadows_same_named_branch_but_find_still_reaches_it( .build()?; // `git workon review` dispatches to the external even though a branch named `review` - // exists — installing the tool must guarantee this reaches it (locked decision 2). + // exists — installing the tool must guarantee this reaches it (locked decision: an + // installed external must be reachable even when a same-named branch exists). let output = cargo_bin_cmd!("git-workon") .current_dir(&fixture) .env("PATH", stub.path())