diff --git a/CONTEXT.md b/CONTEXT.md index 68571717..09c6a180 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -22,6 +22,20 @@ Terms used throughout the `git-workon` codebase. Implementation details do not b **Uncommitted layer** — the synthetic changeset spanning the dirty working tree + index. Appears in a review only when the review is focused where `HEAD` actually is, since uncommitted changes diff against `HEAD`. +## Review Theming + +**Wash** — a background color painted behind diff text to signal that the text changed. Washes carry the diff signal; foreground carries syntax meaning unless a theme says otherwise. _Avoid_: "tint" for the background specifically (see below), "highlight". + +**Line wash** — the wash covering an entire line that contains a change. Answers "something here changed". _Avoid_: "subtle" (renamed — it named intensity, not scope). + +**Edit** — the exact text that changed. On a line paired with a counterpart, the word-diff ranges within it; on a line with no counterpart, the whole line. _Avoid_: "word" (true only for the paired case), "change" (reserved for a file's change kind). + +**Edit wash** — the wash covering an edit. Answers "this precisely is the change". _Avoid_: "strong" (renamed — its intensity-flavored name is what let it drift into a foreground role). + +**Tint foreground** — a text color that encodes added-ness or deleted-ness rather than syntax meaning. Distinct from a wash: same fact, opposite channel. _Avoid_: "diff color" (ambiguous between the two channels). + +**Slot** — one of the sixteen base16 palette positions (`base00`–`base0f`) a theme assigns colors to. A slot has a *role* only when some part of the TUI reads it; the key space accepts all sixteen regardless. + ## Prune Candidate Reasons **BranchDeleted** — the local branch ref for the worktree no longer exists in the repository. Always a prune candidate regardless of flags. diff --git a/Makefile b/Makefile index 316c8aba..3ce84247 100644 --- a/Makefile +++ b/Makefile @@ -25,11 +25,12 @@ build: test: cargo test --workspace -# PTY smoke tests (ignored by default: wall-clock-bound and load-sensitive). -# Spawns the review binary under a pseudo-terminal and plays the terminal's -# side of the theme=auto probe conversation; see tests/pty_smoke.rs. +# PTY tests (ignored by default: wall-clock-bound and load-sensitive). Spawns the review +# binary under a pseudo-terminal; covers the theme=auto probe conversation (see +# tests/pty/pty_smoke.rs) and launch/nav/streamed-startup responsiveness bounds (see +# tests/pty/pty_responsiveness.rs) — merged into one `pty` test binary, see tests/pty/main.rs. smoke: - cargo test -p git-workon-review --test pty_smoke -- --ignored + cargo test -p git-workon-review --test pty -- --ignored fmt: cargo fmt diff --git a/docs/adr/034-review-git-native-config-schema.md b/docs/adr/034-review-git-native-config-schema.md index f1399d66..f25c9405 100644 --- a/docs/adr/034-review-git-native-config-schema.md +++ b/docs/adr/034-review-git-native-config-schema.md @@ -32,6 +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..bind. = "" ; a keymap entry workon.review.. = ; view config ``` @@ -58,6 +60,12 @@ 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` + 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 + action — an unrecognized key or malformed `#rrggbb` value is a startup warning, not an error. - **Load-time inversion:** on startup, walk every `workon.review.*.bind.*` variable, split values into key tokens, and build the per-view key→action dispatch maps. This pass validates (unknown `bind.` → warning; the action set is enumerable) and detects @@ -67,6 +75,16 @@ workon.review.. = ; view config cascade (confirm > outline-unfocus > selection-cancel > quit) stay hardcoded — they are conventional, safety-sensitive, and the Esc cascade's documented precedence would break if rebound. +- **`reload-config` (`R`, global view, rebindable like any other action):** re-reads the + whole `workon.review.*` tree and swaps it in without restarting — this ADR's schema was + originally "read once at startup"; live reload makes it "read once, re-readable on + demand" instead, with no schema change (the same getters just run again). One exception: + `theme = auto`'s terminal-derivation probe (ADR-035) never re-runs mid-session — it needs + the tty, which the TUI owns once the alternate screen is live, and a second probe + conversation there would corrupt input. Reload caches the startup probe result and reuses + it whenever the resolved theme is `auto`, so switching `theme` to `dark`/`light` takes + effect on reload, but switching back to `auto` reuses the cached base rather than + re-probing. ## Consequences @@ -88,6 +106,45 @@ workon.review.. = ; view config - Adding a rebindable action = adding it to the enumerable action set (code default + dispatch + help entry); it is automatically configurable, validated, and documented. +## Revised (config validation completeness) + +The validation posture above ("an unrecognized key … is a startup warning, not an error") turned +out to hold in only two of the four places it reads as a promise. `workon.review.theme.*` warns on +an unrecognized key, and the bind pass warns on an unknown action — but every *other* key under +`workon.review.*` is read by an explicit getter, so a name no getter asks for is never seen by +anything. A typo'd `workon.review.diff.laoyut` or `workon.review.outline.wdith` is silently +dropped: no warning, no effect, and nothing to distinguish it from a setting that simply had no +visible result. This bit in practice, twice in one session, on two different subsections. + +**Unknown-key detection now covers the whole `workon.review.*` tree**, via a single validation pass +over `entries("workon.review.*")` driven by a central known-key registry: exact scalar names, plus +pattern arms for the two open-ended subspaces (`theme.`, `.bind.`). Any +name no arm claims warns and is ignored, same non-fatal posture as everything else here. + +Scope stops at `workon.review.*` deliberately. That subsection is this crate's exclusively; +`workon.*` at large belongs to `git-workon-lib`, and scanning wider would warn about +`workon.autocopy` and every other key this crate has no business knowing. + +**The registry is a second source of truth, and that is the real cost.** A getter added without a +matching registry entry would make its key warn as unknown *while working correctly* — worse than +the silent-drop it replaces. The mitigation is a drift test that enumerates the getters' keys and +asserts each is claimed by the registry, so the failure lands in CI rather than in a user's footer. +The alternative — threading consumed-key tracking through every getter so the getters *are* the +registry — removes the drift class outright but reworks every reader's signature or call site; the +registry-plus-test was judged the better trade at this schema's size, and the choice is revisitable +if the schema grows a third open-ended subspace. + +**Invalid-value warnings now carry the allowed set and the fallback being applied.** The existing +messages named the offending value but neither what was legal nor what the reader did instead — +`"workon.review.diff.text = 'edt' unrecognized; using default"` leaves a user to go read source or +docs for both halves. They now read `(valid: syntax, tint, edit); using default 'syntax'`, and the +range-checked and color-format cases get the same treatment. Theme keys keep saying `ignoring` +rather than naming a default, because an ignored override genuinely has no default to apply — the +underlying scheme's value stands. + +**Unknown keys suggest a nearest match** by edit distance against the registry when one is close +enough, since the overwhelmingly common cause of an unknown key is a typo of a real one. + ## References - [ADR-006](006-git-native-config.md) — git-native config under `workon.*` this extends diff --git a/docs/adr/035-review-theming-base16-hybrid.md b/docs/adr/035-review-theming-base16-hybrid.md index fd0cf817..2a6f51ee 100644 --- a/docs/adr/035-review-theming-base16-hybrid.md +++ b/docs/adr/035-review-theming-base16-hybrid.md @@ -116,6 +116,24 @@ ANSI-less slots are still synthesized as above; `parse` → `build_base16` → ` `palette_for_auto` fallback decision are all pure and unit-tested, with only the timed `/dev/tty` 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 +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* +would have picked. Dogfooding `auto` against laserwave showed the curated washes as the one +discordant element (generic red/green under a personalized syntax palette), and laserwave itself +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. +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 +no ANSI counterpart to derive from, and deriving them from probed base0D/base0C produces +surprises (a teal cursor row on an aqua-leaning theme), so that judgment stays curated-or-overridden. + ## Consequences - Light/dark ships as curated base16 schemes now; **terminal-derivation is first-class from @@ -144,6 +162,158 @@ 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) + +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 +override keys**, not named bundled schemes. `workon.review.theme.*` (a subsection distinct from +`workon.review.theme` itself — both coexist, since git parses `[workon "review"] theme = …` and +`[workon "review.theme"] base00 = …` as different subsections) accepts: + +| Key | Meaning | Palette field(s) rewritten | +| --- | --- | --- | +| `base00`–`base0f` (lowercase) | base16 slot override | role-mapped field(s) below, plus every `syntax` entry whose capture→slot template maps to that slot | +| `base00` | canvas background | `background` (and sets `paint_canvas: true`) | +| `base03` | dim/comment ramp step | `dim` | +| `base04` | gutter/divider ramp step | `gutter` | +| `base05` | default text | `foreground` | +| `base08` | red accent | `error_fg` | +| `base09` | orange accent | `modified_fg` | +| `base0a` | yellow accent | `warn_fg` | +| `base0b` | green accent | `current_fg` | +| `base0c` | cyan accent | `heading_fg` | +| `del-subtle`, `del-strong`, `add-subtle`, `add-strong`, `del-staged-subtle`, `del-staged-strong`, `add-staged-subtle`, `add-staged-strong`, `cursor-bg`, `selection-bg`, `cursor-unfocused-bg`, `pane-header-focused-fg` | diff/cursor tint override (kebab-case, mirroring the `Palette` field names) | the matching field, verbatim | + +Values are `#rrggbb` or bare `rrggbb` (six hex digits only — no 3-digit shorthand). Applied via +`Palette::apply_overrides`, on top of whichever base (`dark`/`light`/`auto`'s probe) was already +resolved — the mechanism is base-agnostic, so an override key works identically regardless of +`workon.review.theme`'s selection. **Uniform slot rule:** a slot override rewrites its +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 +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 +hard error. + +**Named bundled schemes explicitly deferred.** `theme = ` selecting a whole +vendored base16 scheme (e.g. from tinted-theming/schemes, MIT-licensed and so licensing-clean +to vendor) was considered and set aside — the override-key tier covers the immediate need, and +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-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 +picked by `light`) rather than also going `Reset`, since `render.rs` has no non-color channel +(reverse/dim) to substitute for them and changing `render.rs` was out of scope. Add and Del share +one ladder — colorless mode can't carry that distinction by hue, so it falls to gutter +glyph/structure instead, an accepted degradation. `main.rs` applies this last, after theme +resolution AND override application (`NO_COLOR` is an env kill-switch that wins over any +`workon.review.theme.*` override), when `NO_COLOR` is set to any non-empty value (`no-color.org`); +`FORCE_COLOR` is deliberately not consulted — it answers a different question (this repo's own +test/output-capture posture), not "does this user want THIS tool's colors." One wrinkle, +found by driving the real binary under a PTY: crossterm ALSO honors `NO_COLOR`, by stripping +every color SGR at the output layer — which would erase the grayscale washes too and leave +cursor/selection/staged attribution invisible. The app owns `NO_COLOR` semantics at the palette +level instead, so the mono branch calls `crossterm::style::force_color_output(true)` to disable +that blanket suppression and let the achromatic ladders through. That same re-enable, however, +also re-opens the icon color channel for `icons::icon_for_path`'s hardcoded per-filetype `Rgb` — +a palette-EXTERNAL color source `mono()`'s own `Color::Reset` fields can't reach — so `Palette` +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) + +CS1'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 +motivating case: `add-strong #2d4654` on `background #27212e`) those letters land near 1.6:1 +contrast and are effectively invisible. + +The underlying axis was never intensity. It is **attribution precision**: one wash says "this line +contains a change", the other says "this exact text IS the change". Renamed accordingly, and split +across the two color channels: + +| Old key | New key | Meaning | +| --- | --- | --- | +| `del-subtle` | `del-line-bg` | wash for a line containing a deletion | +| `del-strong` | `del-edit-bg` | wash for the deleted text itself | +| `add-subtle` | `add-line-bg` | wash for a line containing an addition | +| `add-strong` | `add-edit-bg` | wash for the added text itself | +| `del-staged-subtle` | `del-staged-line-bg` | staged counterparts of the four above | +| `del-staged-strong` | `del-staged-edit-bg` | | +| `add-staged-subtle` | `add-staged-line-bg` | | +| `add-staged-strong` | `add-staged-edit-bg` | | +| — | `add-fg` | tint foreground for added text | +| — | `del-fg` | tint foreground for deleted text | +| — | `add-staged-fg` | staged counterparts | +| — | `del-staged-fg` | | + +Unqualified keys mean **unstaged (or combined-view)**; only the staged side is spelled out. The +asymmetry is deliberate — the unqualified form is the one most themes set, and lengthening it to +`add-unstaged-line-bg` taxes the common case to remove an ambiguity the table resolves. + +**Why `edit` and not `word`.** `content_spans` paints the edit wash across a line's full width when +that line has no counterpart to word-diff against (a pure insertion or deletion). A `word` name +would be false in exactly that branch. `edit` is honest in both: on an unpaired line, the whole +line *is* the edit. The term is also standard diff vocabulary (edit script, edit distance) and +unclaimed elsewhere in this codebase, where `change` already means a file's change kind and +`Changeset` is a domain object. + +**Foregrounds are per-state, not per-scope.** Four foreground keys, not eight: the line/edit +distinction is already carried by the background, and a foreground shift on top of a background +shift double-encodes one fact. The cost is that a theme cannot express "dimmed line, bright changed +words" — accepted, as it needs two foregrounds on one line and no scheme here has asked for it. + +**Foreground defaults role-map to the accent slots**, matching how `error_fg`/`modified_fg` already +take base08/base09: `add-fg` ← base0B, `del-fg` ← base08. The staged pair dims toward base00, so +staged-ness reads in both channels — but **contrast-clamped**, not a fixed ratio. A flat 40% dim +collapses to 1.65:1 on a theme that sets its staged washes equal to its unstaged ones (staged-ness +then has no background signal, and the foreground is dimming against a full-strength wash). The +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 +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. + +**The outline's X/Y status letters take `add-fg`/`del-fg`** — the bug that prompted this revision. +They are one concept with diff text ("the foreground color of added-ness"), so they share the key +rather than getting a dedicated pair. This does couple outline chrome to a diff key: retinting diff +text also retints the status column. Accepted; a theme wanting them apart can be revisited if it +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 +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 +**wherever the edit wash is painted, the tint foreground is painted** — one rule covering both +branches, rather than a foreground/background disagreement of the kind that produced the original +`strong` drift. + +**base01 and base02 gain roles** (→ `filler_fg`, `selection_bg`), joining base03→`dim` and +base04→`gutter`. They were accepted by the parser and wired to nothing, so setting them failed +silently. The uniform slot rule is unchanged and the no-clobber rule survives: slot overrides seed, +tint keys still apply last and verbatim, so an explicit `selection-bg` beats a `base02`. +**base06, base07, and base0f remain unmapped** — nothing in this TUI is brighter than its +foreground, and base0f is base16's legacy grab-bag. They parse (namespace uniformity) and do +nothing, now documented rather than surprising. + +**Migration is a hard rename.** The eight old wash keys are simply unrecognized and hit the +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 +`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 +remain valid. + ## References - [ADR-034](034-review-git-native-config-schema.md) — `workon.review.theme` config key diff --git a/git-workon-fixture/src/fixture_builder.rs b/git-workon-fixture/src/fixture_builder.rs index b2ba34b1..6e16ed14 100644 --- a/git-workon-fixture/src/fixture_builder.rs +++ b/git-workon-fixture/src/fixture_builder.rs @@ -233,6 +233,7 @@ pub struct FixtureBuilder<'fixture> { partially_staged_files: Vec<(String, String, String, String)>, // (path, committed, staged, workdir) untracked_symlinks: Vec<(String, String)>, // (path, target) — target need not exist executable_unstaged_files: Vec<(String, String, String)>, // (path, committed, modified), mode 0o100755 + executable_untracked_files: Vec<(String, String)>, // (path, content), mode 0o100755 } impl<'fixture> FixtureBuilder<'fixture> { @@ -258,6 +259,7 @@ impl<'fixture> FixtureBuilder<'fixture> { partially_staged_files: Vec::new(), untracked_symlinks: Vec::new(), executable_unstaged_files: Vec::new(), + executable_untracked_files: Vec::new(), } } @@ -591,6 +593,20 @@ impl<'fixture> FixtureBuilder<'fixture> { self } + /// Like [`untracked_file`](Self::untracked_file), but `path` is written with the executable + /// bit set (`chmod 0o755`) — needed to pin that a line-precise stage of an untracked file's + /// content preserves the real file mode in the synthesized `new file mode` header, rather + /// than hardcoding `100644`. + /// + /// Unix-only ([`std::os::unix::fs::PermissionsExt`]); applies to the LAST worktree added, or + /// the main repo if none. Errors at [`build`](Self::build) if the fixture is `bare(true)` + /// with no worktree. + pub fn executable_untracked_file(mut self, path: &str, content: &str) -> Self { + self.executable_untracked_files + .push((path.to_string(), content.to_string())); + self + } + /// Commit `path` with `committed_content` on the cwd repo's branch during `build()` /// (moving the branch tip, in the same baseline-commit block as /// [`unstaged_file`](Self::unstaged_file)), then remove it from the working tree — a @@ -773,11 +789,13 @@ impl<'fixture> FixtureBuilder<'fixture> { || !self.deleted_files.is_empty() || !self.partially_staged_files.is_empty() || !self.untracked_symlinks.is_empty() - || !self.executable_unstaged_files.is_empty(); + || !self.executable_unstaged_files.is_empty() + || !self.executable_untracked_files.is_empty(); if has_index_state && self.bare && self.worktrees.is_empty() { return Err( "staged_file/unstaged_file/untracked_file/deleted_file/partially_staged_file/\ - untracked_symlink/executable_unstaged_file require a working tree: fixture is \ + untracked_symlink/executable_unstaged_file/executable_untracked_file require a \ + working tree: fixture is \ bare(true) with no worktree" .into(), ); @@ -1266,6 +1284,21 @@ impl<'fixture> FixtureBuilder<'fixture> { std::fs::write(&abs_path, content)?; } + #[cfg(unix)] + for (file_path, content) in &self.executable_untracked_files { + use std::os::unix::fs::PermissionsExt; + let abs_path = cwd_path.join(file_path); + if let Some(parent) = abs_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&abs_path, content)?; + std::fs::set_permissions(&abs_path, std::fs::Permissions::from_mode(0o755))?; + } + #[cfg(not(unix))] + if !self.executable_untracked_files.is_empty() { + return Err("executable_untracked_file is unix-only".into()); + } + for (file_path, _committed) in &self.deleted_files { std::fs::remove_file(cwd_path.join(file_path))?; } diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index c54ab7e4..7169e51d 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -18,8 +18,8 @@ use workon::{Changeset, ChangesetSpan}; use crate::acquire::{ChangesetDiff, WorktreeDiffs}; use crate::align::{ - align_file, collapse_gaps_with_expansions, gap_hidden_range, inline_rows, AlignedRow, CellKind, - DisplayRow, GapExpansion, InlineRow, Row, + align_file, collapse_gaps, collapse_gaps_with_expansions, gap_hidden_range, inline_rows, + AlignedRow, CellKind, DisplayRow, GapExpansion, InlineRow, Row, }; use crate::apply::{Git2Applier, StageVerb}; use crate::config::RawViewConfig; @@ -252,6 +252,48 @@ impl FileView { self.rebuild_rows(); } + /// Collapse every gap back to the original, freshly-loaded window, discarding every + /// [`Self::expand_gap`]/[`Self::scope_expand_gap`] accumulated since. An empty + /// [`Self::expansions`] map already IS that original state (what [`Self::load`] starts with), + /// so nothing-to-discard returns `false` without rebuilding — the caller uses that to leave + /// selection/scroll state alone when the row space did not reshape (the same rule + /// [`App::expand_gap_at_cursor`] documents). Driven by `zM` — see [`App::reset_gaps`]. + pub fn reset_expansions(&mut self) -> bool { + if self.expansions.is_empty() { + return false; + } + self.expansions.clear(); + self.rebuild_rows(); + true + } + + /// Reveal every collapsed gap in the file at once. Collects the gap keys from the BASE + /// collapse ([`collapse_gaps`], not [`Self::display`]) so a gap that's already partially + /// expanded is still caught — the base collapse always has every gap the file can have, while + /// the current display only shows the ones still collapsed under the CURRENT expansions. + /// Returns whether anything actually changed (some gap was not already fully revealed); + /// a gapless or already-fully-expanded file skips the rebuild and returns `false`, same + /// contract as [`Self::reset_expansions`]. + pub fn expand_all_gaps(&mut self) -> bool { + let mut changed = false; + for row in collapse_gaps(&self.aligned) { + if let DisplayRow::Gap { key, .. } = row { + changed |= !self.expansions.get(&key).is_some_and(|e| e.full); + self.expansions.insert( + key, + GapExpansion { + full: true, + ..Default::default() + }, + ); + } + } + if changed { + self.rebuild_rows(); + } + changed + } + /// CS9's 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 @@ -582,7 +624,27 @@ pub enum Role { Staged, } -/// The zoom the user *requested* via `z` — persists across file navigation (like [`Layout`]). The +/// `workon.review.diff.text` (see ADR-035's "Revised (CS11, 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 +/// syntax highlighting regardless of this setting; only changed (`Del`/`Add`) lines are affected. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DiffTextMode { + /// Tree-sitter foreground everywhere, changed lines included — today's behavior, and the + /// pixel-identity default. + #[default] + Syntax, + /// Changed lines take the tint foreground (`add_fg`/`del_fg`, or the staged pair per the + /// line's attribution) across their full width. + Tint, + /// Syntax stays on the line; only the edit spans take the tint foreground. On an unpaired + /// line (no word-diff counterpart), the tint foreground spans the full width — wherever the + /// edit background wash is painted, the tint foreground is painted too. + 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. @@ -657,65 +719,113 @@ pub fn effective_zoom( } } -/// Parse `workon.review.outline.mode` (CS7) into an [`OutlineMode`]. Canonical strings mirror -/// the variant names, kebab-cased: `flat`, `stack`, `tree`, `stack-tree`. `None` on anything -/// else — [`App::apply_view_config`] falls back to [`OutlineMode::default`] and warns. -fn parse_outline_mode(raw: &str) -> Option { - match raw { - "flat" => Some(OutlineMode::Flat), - "stack" => Some(OutlineMode::Stack), - "tree" => Some(OutlineMode::Tree), - "stack-tree" => Some(OutlineMode::StackTree), - _ => None, - } +/// 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 +/// "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 { + options + .iter() + .map(|(name, _)| *name) + .collect::>() + .join(", ") } -/// Parse `workon.review.outline.order` (CS3) into an [`OutlineOrder`]. Canonical strings mirror -/// the variant names, kebab-cased: `head-first`, `base-first`. `None` on anything else — -/// [`App::apply_view_config`] falls back to [`OutlineOrder::default`] and warns. -fn parse_outline_order(raw: &str) -> Option { - match raw { - "head-first" => Some(OutlineOrder::HeadFirst), - "base-first" => Some(OutlineOrder::BaseFirst), - _ => None, - } +/// The canonical config string for `options`' `T::default()` variant — reads the enum's real +/// `Default` impl rather than hardcoding a name, so a warning's "using default '…'" can never +/// drift from what `Default::default()` actually produces. +fn default_option_name( + options: &'static [(&'static str, T)], +) -> &'static str { + options + .iter() + .find(|(_, value)| *value == T::default()) + .map(|(name, _)| *name) + .expect("T::default() has a canonical name listed in `options`") } -/// Parse `workon.review.icons` (CS5) into an [`IconMode`]. Canonical strings mirror -/// the variant names, kebab-cased: `nerd`, `none`. `None` on anything else — -/// [`App::apply_view_config`] falls back to [`IconMode::default`] (also `none` — CS5's -/// no-auto-detection default) and warns. -fn parse_icon_mode(raw: &str) -> Option { - match raw { - "nerd" => Some(IconMode::Nerd), - "none" => Some(IconMode::None), - _ => None, - } +/// 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. +fn parse_option(options: &[(&str, T)], raw: &str) -> Option { + options + .iter() + .find(|(name, _)| *name == raw) + .map(|(_, value)| *value) } -/// Parse `workon.review.diff.layout` (CS7) into a [`Layout`]. Canonical strings mirror the -/// variant names: `sbs`, `inline`. `None` on anything else — [`App::apply_view_config`] falls -/// back to [`Layout::default`] and warns. -fn parse_diff_layout(raw: &str) -> Option { - match raw { - "sbs" => Some(Layout::Sbs), - "inline" => Some(Layout::Inline), - _ => None, - } +/// Resolve one `workon.review.*` view-config string against `options`: [`parse_option`] on a +/// hit, or `T::default()` plus a pushed "unrecognized (valid: …); using default '…'" warning on +/// a miss — the shared warn-and-default shape every site in [`App::apply_view_config`] needs. +/// `key` is the fully-qualified config key (e.g. `"workon.review.outline.mode"`) as it should +/// read in the warning. +fn resolve_option( + key: &str, + raw: &str, + options: &'static [(&'static str, T)], + warnings: &mut Vec, +) -> T { + parse_option(options, raw).unwrap_or_else(|| { + let valid = valid_options_list(options); + let default = default_option_name(options); + warnings.push(format!( + "{key} = '{raw}' unrecognized (valid: {valid}); using default '{default}'" + )); + T::default() + }) } -/// Parse `workon.review.diff.zoom` (CS7) into a [`Zoom`]. Canonical strings mirror the variant -/// names: `split`, `combined`, `unstaged`, `staged`. `None` on anything else — -/// [`App::apply_view_config`] falls back to [`Zoom::default`] and warns. -fn parse_diff_zoom(raw: &str) -> Option { - match raw { - "split" => Some(Zoom::Split), - "combined" => Some(Zoom::Combined), - "unstaged" => Some(Zoom::Unstaged), - "staged" => Some(Zoom::Staged), - _ => None, - } -} +/// `workon.review.outline.mode` (CS7)'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. +const OUTLINE_MODE_OPTIONS: &[(&str, OutlineMode)] = &[ + ("flat", OutlineMode::Flat), + ("stack", OutlineMode::Stack), + ("tree", OutlineMode::Tree), + ("stack-tree", OutlineMode::StackTree), +]; + +/// `workon.review.outline.order` (CS3)'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)] = &[ + ("head-first", OutlineOrder::HeadFirst), + ("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 +/// 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 +/// 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`] +/// — [`App::apply_view_config`] falls back to [`DiffTextMode::default`] and warns on anything +/// not listed here. +const DIFF_TEXT_OPTIONS: &[(&str, DiffTextMode)] = &[ + ("syntax", DiffTextMode::Syntax), + ("tint", DiffTextMode::Tint), + ("edit", DiffTextMode::Edit), +]; /// CS4: 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. @@ -1204,9 +1314,13 @@ pub struct App { 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 + /// 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 + /// [`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. split_focus: SplitPane, @@ -1304,6 +1418,19 @@ 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 + /// flag here and the event loop — which DOES hold those — does the actual reload. + config_reload_requested: bool, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -1467,6 +1594,7 @@ impl App { highlighter: TsHighlighter::new(), layout: Layout::default(), zoom: Zoom::default(), + diff_text: DiffTextMode::default(), split_focus: SplitPane::Unstaged, notice: None, queue: StagingQueue::new(), @@ -1484,6 +1612,8 @@ impl App { generation: 1, wave_failure_notified: false, pending_wave: None, + zoom_key_label: "Z".to_string(), + config_reload_requested: 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 @@ -1502,6 +1632,14 @@ impl App { 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 @@ -2210,6 +2348,27 @@ impl App { self.pending_wave.take() } + /// `App`'s own repo handle — read-only access for a caller (the reload command) that needs to + /// re-read `workon.review.*` config through the SAME handle `App` already opened, rather than + /// opening a second one onto the same on-disk repo. + pub fn repo(&self) -> &Repository { + &self.repo + } + + /// Raise a `reload-config` (`R`) request — picked up (and cleared) by the event loop via + /// [`Self::take_config_reload_request`]. `App` can't do the reload itself: it doesn't own the + /// `Keymap`/`Palette` that get swapped (see [`Self::config_reload_requested`]'s doc comment). + pub fn request_config_reload(&mut self) { + self.config_reload_requested = true; + } + + /// Take the pending `reload-config` request, if any — one-shot, mirroring + /// [`Self::take_pending_wave`]'s take-and-clear shape: a second call with nothing new + /// requested in between returns `false`. + pub fn take_config_reload_request(&mut self) -> bool { + std::mem::take(&mut self.config_reload_requested) + } + /// Apply one loader result (ADR-037's chokepoint, the `FileReady` inbox arm routes here): /// dropped outright on a generation mismatch (`gen != self.generation` — the world it was /// computed against no longer exists, see [`Self::generation`]'s doc comment). Otherwise: @@ -2225,7 +2384,7 @@ impl App { /// [`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 - /// (`z` is exempt from force-completion — [`Self::open_current`] re-defers with + /// (`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. /// When unsatisfied, `open_pending` stays set and `open_pending_dispatched` resets to @@ -2357,7 +2516,7 @@ impl App { } } - /// Cycle the requested zoom `Split → Combined → Unstaged → Staged → Split` (`z`). The new zoom + /// 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) { @@ -3636,8 +3795,13 @@ impl App { } /// The `(scroll, cursor)` a split pane renders with: the focused pane contributes its own - /// `scroll` and `Some(cursor)` (so the cursor highlight draws there); the unfocused pane - /// contributes its stashed scroll and `None` (no highlight). Combined resolves to the focused + /// `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 + /// 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 /// (single) state. pub(crate) fn pane_render_state(&self, role: Role) -> (usize, Option) { let pane = match role { @@ -3648,7 +3812,7 @@ impl App { if self.split_focus == pane { (self.scroll, Some(self.cursor)) } else { - (self.alt.scroll, None) + (self.alt.scroll, Some(self.alt.cursor)) } } @@ -3790,6 +3954,41 @@ impl App { self.clamp_cursor(); } + /// Collapse every gap in the focused file's view back to the original, freshly-loaded state, + /// discarding any accumulated [`Self::expand_gap_at_cursor`] reveals (`zM`, mirroring the + /// outline's `OutlineCollapseAll`). Scope: the focused view only ([`FileView::expansions`] is + /// per-file, same as a refresh already clears it). A no-op when there's no loaded view + /// (mirrors [`Self::expand_gap_at_cursor`]'s guard). + /// + /// `zM`/`zR` share the `z` prefix in `View::Diff`, which is why `cycle-zoom` moved off bare + /// `z` to `Z` (see `keymap::tests::shift_z_dispatches_cycle_zoom_with_no_collisions`'s doc + /// comment for the mechanics that forced the rebind). + pub fn reset_gaps(&mut self) { + let Some(view) = self.current_view() else { + return; + }; + // Tail only when the row space actually reshaped — a no-op zM must leave an in-progress + // selection alone, the same rule expand_gap_at_cursor documents above. + if view.reset_expansions() { + self.cancel_selection(); + self.derive_scroll(); + self.clamp_cursor(); + } + } + + /// Reveal every collapsed gap in the focused file's view at once (`zR`, mirroring the + /// outline's `OutlineExpandAll`). Scope and tail mirror [`Self::reset_gaps`]. + pub fn expand_all_gaps(&mut self) { + let Some(view) = self.current_view() else { + return; + }; + if view.expand_all_gaps() { + self.cancel_selection(); + self.derive_scroll(); + self.clamp_cursor(); + } + } + /// 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 @@ -3833,8 +4032,17 @@ impl App { self.layout = layout; } - /// Apply `workon.review.outline.width|mode` and `workon.review.diff.layout|zoom` (CS7) as - /// the App's initial view-config state, via the same setters the interactive keys drive + /// 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. + 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 /// (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 @@ -3855,7 +4063,8 @@ impl App { _ => { warnings.push(format!( "workon.review.outline.width = {w} out of range \ - ({MIN_OUTLINE_WIDTH}-{MAX_OUTLINE_WIDTH}); using default" + ({MIN_OUTLINE_WIDTH}-{MAX_OUTLINE_WIDTH}); using default \ + {DEFAULT_OUTLINE_WIDTH}" )); DEFAULT_OUTLINE_WIDTH } @@ -3865,60 +4074,118 @@ impl App { self.set_outline_width(width); let mode = match &raw.outline_mode { - Some(m) => parse_outline_mode(m).unwrap_or_else(|| { - warnings.push(format!( - "workon.review.outline.mode = '{m}' unrecognized; using default" - )); - OutlineMode::default() - }), + Some(m) => resolve_option( + "workon.review.outline.mode", + m, + OUTLINE_MODE_OPTIONS, + &mut warnings, + ), None => OutlineMode::default(), }; self.set_outline_mode(mode); let order = match &raw.outline_order { - Some(o) => parse_outline_order(o).unwrap_or_else(|| { - warnings.push(format!( - "workon.review.outline.order = '{o}' unrecognized; using default" - )); - OutlineOrder::default() - }), + Some(o) => resolve_option( + "workon.review.outline.order", + o, + OUTLINE_ORDER_OPTIONS, + &mut warnings, + ), None => OutlineOrder::default(), }; self.set_outline_order(order); let icons = match &raw.icons { - Some(i) => parse_icon_mode(i).unwrap_or_else(|| { - warnings.push(format!( - "workon.review.icons = '{i}' unrecognized; using default" - )); - IconMode::default() - }), + Some(i) => resolve_option("workon.review.icons", i, ICON_MODE_OPTIONS, &mut warnings), None => IconMode::default(), }; self.set_icon_mode(icons); let layout = match &raw.diff_layout { - Some(l) => parse_diff_layout(l).unwrap_or_else(|| { - warnings.push(format!( - "workon.review.diff.layout = '{l}' unrecognized; using default" - )); - Layout::default() - }), + Some(l) => resolve_option( + "workon.review.diff.layout", + l, + DIFF_LAYOUT_OPTIONS, + &mut warnings, + ), None => Layout::default(), }; self.set_layout(layout); let zoom = match &raw.diff_zoom { - Some(z) => parse_diff_zoom(z).unwrap_or_else(|| { - warnings.push(format!( - "workon.review.diff.zoom = '{z}' unrecognized; using default" - )); - Zoom::default() - }), + 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", + t, + DIFF_TEXT_OPTIONS, + &mut warnings, + ), + None => DiffTextMode::default(), + }; + self.set_diff_text(diff_text); + + warnings + } + + /// Apply a mid-session `workon.review.outline.*`/`workon.review.diff.*` change (the + /// `reload-config` command, `R`) — the reload counterpart to [`Self::apply_view_config`]. + /// + /// [`Self::apply_view_config`]'s setters deliberately skip re-deriving `cursor`/`scroll`/ + /// outline state, because [`Self::open_current`] (called once right after it, at startup) + /// derives all of that fresh. Reload can't call `open_current` — that would reset the + /// cursor/scroll position and re-arm a deferred load, throwing away the user's place for what + /// should be a cheap recolor/rebind (the exact regression this design exists to prevent). + /// 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. + pub fn reload_view_config(&mut self, raw: &RawViewConfig) -> Vec { + let layout_before = self.layout; + let outline_mode_before = self.outline.mode; + let outline_order_before = self.outline.order; + + let warnings = self.apply_view_config(raw); + + if self.layout != layout_before { + // Mirrors `toggle_layout`'s tail: the two layouts' row vectors are different + // coordinate spaces, so a selection anchor doesn't translate across them. + self.selection_anchor = None; + self.clamp_cursor(); + if let EffectiveZoom::Split = self.effective_zoom_for(self.current) { + let role = self.unfocused_split_role(); + let rows = self.role_row_count(self.current, role); + self.alt.cursor = if rows == 0 { + 0 + } else { + self.alt.cursor.min(rows - 1) + }; + } + self.derive_scroll(); + } + + if self.outline.mode != outline_mode_before || self.outline.order != outline_order_before { + // Mirrors `outline_cycle_mode`'s tail: the row list's shape just changed, so a stale + // pan offset or cursor index could easily land past the new mode's content. + self.outline.hscroll = 0; + self.sync_outline_to_current(); + } + warnings } @@ -3984,8 +4251,9 @@ impl App { Severity::Error, ); } else { + let key = &self.zoom_key_label; self.notify( - format!("{verb} in the unstaged/staged pane — cycle zoom (z)"), + format!("{verb} in the unstaged/staged pane — cycle zoom ({key})"), Severity::Error, ); } @@ -4433,11 +4701,11 @@ 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 hunk patch - /// can express (the modified-file notice — line ops need a two-sided hunk, per - /// [`ops::is_hunk_patchable`]), 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 type's docs), drains once, and clears the selection. + /// selection up). Refuses on the combined view (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 + /// type's docs), drains once, and clears the selection. fn stage_selection(&mut self) { if self.cur().diff.files.is_empty() { self.cancel_selection(); @@ -4450,9 +4718,9 @@ impl App { let Some(verb) = Self::verb_for_role(role) else { return; }; - if !ops::is_hunk_patchable(&self.cur().diff.files[self.current]) { + if !ops::supports_line_ops(&self.cur().diff.files[self.current]) { self.notify( - "line staging needs a modified file — use s/S for the whole file", + line_ops_refusal_message(&self.cur().diff.files[self.current]), Severity::Error, ); return; @@ -4470,9 +4738,13 @@ impl App { } /// Request confirmation to discard the active line selection from the worktree (`d` with a - /// selection up). Discard acts only in the unstaged pane; refuses otherwise, on a - /// non-hunk-patchable file, or on a selection with no changed lines. The confirm prompt states - /// the TRUE scope (total lines across N hunks); the discard runs on `y`. + /// selection up). Discard acts only in the unstaged pane; refuses otherwise, on a file no + /// line op can express ([`ops::supports_line_ops`], per-status notice), or on a selection + /// with no changed lines. A selection covering ALL of an `Untracked` file's lines is routed + /// to the whole-file discard confirm instead (fork 2 of the line-ops-on-one-sided-files + /// handoff): the file gets removed, not left behind empty, and the prompt says so. Otherwise + /// the confirm prompt states the TRUE scope (total lines across N hunks); the discard runs + /// on `y`. fn discard_selection(&mut self) { if self.cur().diff.files.is_empty() { self.cancel_selection(); @@ -4486,11 +4758,9 @@ impl App { self.notify("discard acts in the unstaged pane", Severity::Error); return; } - if !ops::is_hunk_patchable(&self.cur().diff.files[self.current]) { - self.notify( - "line staging needs a modified file — use s/S for the whole file", - Severity::Error, - ); + let file = &self.cur().diff.files[self.current]; + if !ops::supports_line_ops(file) { + self.notify(line_ops_refusal_message(file), Severity::Error); return; } let selections = self.selection_line_ops(); @@ -4498,6 +4768,16 @@ impl App { self.notify("no changed lines in selection", Severity::Error); return; } + if file.status == FileStatus::Untracked && selection_covers_every_line(file, &selections) { + let path = file.path.clone(); + self.request_confirm( + format!("Discard `{path}`? This removes the untracked file. (y/n)"), + PendingOp::DiscardFile { + file_idx: self.current, + }, + ); + return; + } let total: usize = selections .iter() .map(|(_, s)| s.keep_dels.len() + s.keep_adds.len()) @@ -4518,6 +4798,51 @@ impl App { } } +/// Per-status footer refusal for a line-op gate failure (fork 4 of the line-ops-on-one-sided-files +/// handoff): name the blocked status specifically rather than the old one-size-fits-all +/// "needs a modified file" wording, which stopped being accurate once +/// [`ops::supports_line_ops`] started admitting `Untracked`/`Added` too. The statuses that still +/// reach this message are exactly [`ops::supports_line_ops`]'s refusals: `Deleted`, `Unmerged`, +/// and any binary file regardless of status. +fn line_ops_refusal_message(file: &FileChange) -> String { + if file.is_binary { + return "line staging isn't available for a binary file — use s/S for the whole file" + .to_string(); + } + let noun = match file.status { + FileStatus::Deleted => "deleted file", + FileStatus::Unmerged => "unmerged file", + // Every other status passes `ops::supports_line_ops`, so this arm is unreachable in + // practice — kept as a safe fallback rather than a `panic!`/`unreachable!` (a routing + // bug elsewhere should surface as a slightly generic notice, not a crash). + _ => "file", + }; + format!("line staging isn't available for a {noun} — use s/S for the whole file") +} + +/// Fork 2's full-selection detector: whether `selections` keeps every [`LineKind::Addition`] +/// line across ALL of `file`'s hunks — the shape [`App::discard_selection`] must route to the +/// whole-file discard confirm instead of a partial line discard (an `Untracked` file has no +/// deletions to speak of, so "every addition kept" is "the whole file selected"). `false` when +/// `file` has no addition lines at all (nothing to have "covered everything"). +fn selection_covers_every_line(file: &FileChange, selections: &[(usize, LineSelection)]) -> bool { + let total_adds: usize = file + .hunks + .iter() + .map(|h| { + h.lines + .iter() + .filter(|l| l.kind == LineKind::Addition) + .count() + }) + .sum(); + if total_adds == 0 { + return false; + } + let selected_adds: usize = selections.iter().map(|(_, sel)| sel.keep_adds.len()).sum(); + selected_adds == total_adds +} + /// Resolve a selection's kept old-del / new-add LINE NUMBERS to a [`LineSelection`] — whose keys /// are indices into `hunk.lines`, not line numbers (see [`LineSelection`]'s own docs). Walks the /// hunk once, keeping each deletion whose `old_lnum` is in `keep_old_dels` and each addition whose @@ -4675,7 +5000,7 @@ pub enum LoadedViews { /// Whether a loaded result's SHAPE — what zoom it was built against, per [`FileLoadSpec::zoom`] /// — still matches `current_zoom`, the current file's effective zoom at result-apply time. Used /// by [`App::apply_file_ready`] to tell a still-useful deferred-open result apart from one a -/// mid-load `z` cycle outran: `Single` satisfies only the SAME role's `Single`, `Split` +/// mid-load `Z` cycle outran: `Single` satisfies only the SAME role's `Single`, `Split` /// satisfies only `Split` (never the reverse — a `Split` result doesn't seat a `Single` open, /// and vice versa, even though `set_if_absent` already caches whichever roles it carries). fn loaded_views_satisfy(views: &LoadedViews, current_zoom: EffectiveZoom) -> bool { @@ -4946,11 +5271,12 @@ mod tests { use super::test_support::app_from_fixture; use super::{ build_file_views, find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, DiffState, - EffectiveZoom, HitRegions, Layout, LoadedViews, Region, Role, Severity, Summary, - SummaryTarget, Zoom, DEFAULT_OUTLINE_WIDTH, HSCROLL_STEP, SCROLLOFF, + DiffTextMode, EffectiveZoom, HitRegions, Layout, LoadedViews, Region, Role, Severity, + Summary, SummaryTarget, Zoom, DEFAULT_OUTLINE_WIDTH, HSCROLL_STEP, MAX_OUTLINE_WIDTH, + MIN_OUTLINE_WIDTH, SCROLLOFF, }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; - use crate::config::ReviewConfig; + use crate::config::{RawViewConfig, ReviewConfig}; use crate::icons::IconMode; use crate::model::FileStatus; use crate::outline::{OutlineItem, OutlineMode, OutlineOrder, StagedStatus}; @@ -5329,7 +5655,7 @@ mod tests { .expect("first take dispatches against the Split zoom"); assert_eq!(spec.zoom, EffectiveZoom::Split); - // Mid-load `z`: CycleZoom is exempt from force-completion, so this re-defers the open + // Mid-load `Z`: CycleZoom is exempt from force-completion, so this re-defers the open // against the NEW zoom instead of blocking for it. app.cycle_zoom(); assert!( @@ -7564,6 +7890,42 @@ mod tests { )); } + #[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}; + + // `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; + 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("(F5)"), "got: {:?}", notice.text); + } + #[test] fn discard_hunk_in_staged_pane_refuses() { use super::{Severity, Zoom}; @@ -7930,8 +8292,8 @@ mod tests { } #[test] - fn line_stage_on_untracked_file_refuses_with_modified_file_message() { - use super::Severity; + fn line_stage_on_untracked_file_stages_only_the_selected_lines() { + use crate::outline::StagedStatus; let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") @@ -7940,23 +8302,109 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); app.open_current(); - app.start_selection(); // untracked file has an unstaged change, so selection is allowed + app.start_selection(); // single row: just the first addition line ("x\n") + app.stage_hunk(); + + assert!( + app.notice.is_none(), + "line staging on an untracked file must succeed now; got notice: {:?}", + app.notice + ); + assert!( + app.selection_anchor.is_none(), + "selection clears after apply" + ); + + let repo = fixture.repo().unwrap(); + repo.assert(predicate::repo::index_blob_equals( + "new.txt", + b"x\n".to_vec(), + )); + repo.assert(predicate::repo::workdir_file_equals( + "new.txt", + b"x\ny\nz\n".to_vec(), + )); + + assert_eq!( + app.cur().staged_status(0), + StagedStatus::Partial, + "a partially staged untracked file shows Partial in the outline" + ); + } + + /// Regression guard (fork 4): a `Deleted` file still refuses line staging — unlike + /// `Untracked`/`Added`, a deletion has no meaningful "one-sided" creation shape — but the + /// notice now names the status instead of the old one-size-fits-all "modified file" wording. + #[test] + fn line_stage_on_deleted_file_still_refuses_with_per_status_message() { + use super::Severity; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .deleted_file("gone.txt", "bye\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.start_selection(); app.stage_hunk(); let notice = app.notice.as_ref().expect("line staging must refuse here"); assert_eq!(notice.severity, Severity::Error); assert!( - notice.text.contains("line staging needs a modified file"), + notice + .text + .contains("line staging isn't available for a deleted file"), "got: {:?}", notice.text ); let repo = fixture.repo().unwrap(); assert!( - !predicate::repo::has_staged_file("new.txt").eval(repo), + !predicate::repo::has_staged_deletion("gone.txt").eval(repo), "a refused line stage must not touch the index" ); } + /// Fork 2: discarding a selection that covers EVERY line of an untracked file routes to the + /// whole-file discard confirm (file removal), not a partial line-discard confirm — and does + /// NOT leave an empty file behind. + #[test] + fn discard_selection_covering_the_whole_untracked_file_confirms_file_removal() { + use super::PendingOp; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "only\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.start_selection(); // single-line file: this one row IS the whole file + app.discard_hunk(); // active selection -> discard_selection + + let confirm = app + .pending_confirm + .as_ref() + .expect("full-file untracked discard requests a confirm"); + assert!( + confirm.prompt.contains("removes the untracked file"), + "expected file-removal wording, got: {:?}", + confirm.prompt + ); + assert_eq!( + confirm.op, + PendingOp::DiscardFile { file_idx: 0 }, + "must route to the whole-file discard op, not a partial line discard" + ); + + app.resolve_confirm(true); + let repo = fixture.repo().unwrap(); + assert!( + !repo.workdir().unwrap().join("new.txt").exists(), + "the untracked file must be removed outright, not left empty" + ); + } + #[test] fn line_stage_of_context_only_selection_refuses() { use super::Severity; @@ -9817,6 +10265,7 @@ mod tests { 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()); } #[test] @@ -9847,7 +10296,16 @@ mod tests { assert_eq!(app.outline_width(), DEFAULT_OUTLINE_WIDTH); assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("outline.width")); + // Full-message pin (config-validation-completeness Decision 5): the range and fallback + // must come from the real `MIN_OUTLINE_WIDTH`/`MAX_OUTLINE_WIDTH`/`DEFAULT_OUTLINE_WIDTH` + // constants, never hardcoded numbers. + assert_eq!( + warnings[0], + format!( + "workon.review.outline.width = 9999 out of range \ + ({MIN_OUTLINE_WIDTH}-{MAX_OUTLINE_WIDTH}); using default {DEFAULT_OUTLINE_WIDTH}" + ) + ); } #[test] @@ -9878,7 +10336,13 @@ mod tests { assert_eq!(app.outline_mode(), OutlineMode::default()); assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("outline.mode")); + // Full-message pin: the valid set and fallback name come from `OUTLINE_MODE_OPTIONS`/ + // `OutlineMode::default`, not a hardcoded string. + assert_eq!( + warnings[0], + "workon.review.outline.mode = 'bogus' unrecognized \ + (valid: flat, stack, tree, stack-tree); using default 'stack'" + ); } #[test] @@ -10005,6 +10469,123 @@ mod tests { assert!(warnings[0].contains("diff.zoom")); } + #[test] + fn diff_text_overrides_default_when_set() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.text", "tint") + .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.diff_text, DiffTextMode::Tint); + } + + #[test] + fn diff_text_invalid_falls_back_to_default_with_warning() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.text", "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.diff_text, DiffTextMode::default()); + assert_eq!(warnings.len(), 1); + // Full-message pin: matches the handoff's target shape verbatim. + assert_eq!( + warnings[0], + "workon.review.diff.text = 'bogus' unrecognized (valid: syntax, tint, edit); \ + using default 'syntax'" + ); + } + + // ── `reload-config` (`R`): request flag + mid-session view-config apply ──── + + #[test] + fn config_reload_request_is_one_shot() { + let fixture = FixtureBuilder::new().build().unwrap(); + let mut app = app_from_fixture(&fixture); + + assert!(!app.take_config_reload_request(), "nothing requested yet"); + + app.request_config_reload(); + assert!( + app.take_config_reload_request(), + "the request just raised must be observed" + ); + assert!( + !app.take_config_reload_request(), + "a second take with nothing new requested must find nothing left" + ); + } + + #[test] + fn reload_view_config_does_not_reset_the_diff_cursor_to_row_0() { + // The key regression this design exists to prevent: `apply_view_config` alone (as + // `open_current` would run after it at startup) resets cursor/scroll via `reset_panes`; + // `reload_view_config` must NOT do that, since a config reload should read as a cheap + // recolor/rebind, not a jump back to the top of the file. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file( + "a.txt", + "one\ntwo\nthree\nfour\nfive\n", + "ONE\ntwo\nTHREE\nfour\nFIVE\n", + ) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.move_cursor_by(2); + let cursor_before = app.cursor; + assert!( + cursor_before > 0, + "test setup: cursor must have moved off row 0" + ); + + // A layout flip exercises `reload_view_config`'s `toggle_layout`-mirroring tail (the + // clamp, not a reset) — the most invasive of the three tails it can run. + let raw = RawViewConfig { + diff_layout: Some("inline".to_string()), + ..Default::default() + }; + let warnings = app.reload_view_config(&raw); + + assert!(warnings.is_empty()); + assert_eq!(app.layout, Layout::Inline); + assert_ne!( + app.cursor, 0, + "reload must not reset the diff cursor to row 0 like open_current/reset_panes would" + ); + } + + #[test] + fn reload_view_config_leaves_the_outline_cursor_valid_after_a_mode_change() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.open = true; + + let raw = RawViewConfig { + outline_mode: Some("tree".to_string()), + ..Default::default() + }; + let warnings = app.reload_view_config(&raw); + + assert!(warnings.is_empty()); + assert_eq!(app.outline_mode(), OutlineMode::Tree); + let items = app.outline_items(); + assert!( + app.outline.cursor < items.len(), + "outline cursor must stay a valid index into the new mode's row list" + ); + } + // ── CS4: summary panel ─────────────────────────────────────────────────────── /// Force the outline open+focused with `mode` and `cursor`, matching the state @@ -11218,6 +11799,151 @@ mod tests { ); } + // ── diff-fold-keys CS3: reset (`zM`) / expand-all (`zR`) gaps ─────────── + + #[test] + fn reset_gaps_collapses_an_expanded_gap_back_to_the_freshly_loaded_shape() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let freshly_loaded_len = app.current_view_ref().unwrap().display.len(); + + let gap_row = only_gap_row(&app); + app.cursor = gap_row; + app.expand_gap_at_cursor(false); + assert!( + app.current_view_ref().unwrap().display.len() > freshly_loaded_len, + "precondition: the gap must actually have expanded" + ); + + app.reset_gaps(); + + let view = app.current_view_ref().unwrap(); + assert_eq!( + view.display.len(), + freshly_loaded_len, + "reset must return the display to its freshly-loaded (fully collapsed) shape" + ); + assert!( + view.display + .iter() + .any(|r| matches!(r, DisplayRow::Gap { .. })), + "a `Gap` row must be back after resetting" + ); + } + + #[test] + fn reset_gaps_with_nothing_expanded_is_a_no_op() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let before_len = app.current_view_ref().unwrap().display.len(); + let before_cursor = app.cursor; + // An in-progress selection must survive a no-op zM — the row space didn't reshape, so + // there's no reason to destroy it (same rule as expand_gap_at_cursor's non-gap no-op). + app.start_selection(); + assert!(app.selection_anchor.is_some()); + + app.reset_gaps(); + + assert_eq!( + app.current_view_ref().unwrap().display.len(), + before_len, + "no-op must not change the row count" + ); + assert_eq!(app.cursor, before_cursor, "no-op must not move the cursor"); + assert!( + app.selection_anchor.is_some(), + "a no-op reset must leave an in-progress selection alone" + ); + } + + #[test] + fn expand_all_gaps_on_a_fully_expanded_file_is_a_no_op_that_keeps_the_selection() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.expand_all_gaps(); + app.start_selection(); + assert!(app.selection_anchor.is_some()); + + app.expand_all_gaps(); + + assert!( + app.selection_anchor.is_some(), + "re-running zR with every gap already revealed must leave the selection alone" + ); + } + + #[test] + fn reset_gaps_keeps_the_cursor_in_bounds_after_collapsing_an_expanded_region() { + 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.expand_gap_at_cursor(false); + // Put the cursor deep inside the just-revealed region, past where the reset shape ends. + app.cursor = app.current_view_ref().unwrap().display.len() - 1; + + app.reset_gaps(); + + let view = app.current_view_ref().unwrap(); + assert!( + app.cursor < view.display.len(), + "cursor must be clamped back into the reset (shorter) display: {} vs len {}", + app.cursor, + view.display.len() + ); + } + + #[test] + fn expand_all_gaps_leaves_no_gap_row_behind() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + + app.expand_all_gaps(); + + let view = app.current_view_ref().unwrap(); + assert!( + !view + .display + .iter() + .any(|r| matches!(r, DisplayRow::Gap { .. })), + "expand-all must reveal every gap: {:?}", + view.display + ); + assert!( + app.cursor < view.display.len(), + "cursor must stay in bounds" + ); + } + + #[test] + fn expand_all_gaps_then_reset_gaps_round_trips_to_the_freshly_loaded_shape() { + let fixture = two_hunks_with_a_wide_gap_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let freshly_loaded_len = app.current_view_ref().unwrap().display.len(); + + app.expand_all_gaps(); + assert!( + app.current_view_ref().unwrap().display.len() > freshly_loaded_len, + "precondition: expand-all must have revealed more rows" + ); + + app.reset_gaps(); + + let view = app.current_view_ref().unwrap(); + assert_eq!( + view.display.len(), + freshly_loaded_len, + "reset must undo an expand-all just as it undoes a partial expansion" + ); + } + // ── CS9: reveal gaps to the enclosing tree-sitter scope ───────────────── /// A `.rs` fixture where both edits sit inside the SAME long function, with a 40-line diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index 4b44bbcc..d8647c79 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -20,6 +20,11 @@ //! theme = dark ; auto | dark | light (default: auto) //! icons = nerd ; nerd | none (default: none) //! +//! [workon "review.theme"] +//! base00 = #101010 ; base16 slot override (base00-base0f, lowercase) +//! base0e = a626a4 ; #rrggbb or bare rrggbb, no 3-digit shorthand +//! cursor-bg = #1a2b3c ; diff/cursor tint override (kebab-case, see ThemeOverrides) +//! //! [workon "review.diff.bind"] //! stage-hunk = s x ; action = key tokens (space-separated) //! @@ -28,6 +33,7 @@ //! //! [workon "review.bind"] //! quit = q esc ; bare `review.bind` = global view (active in every view) +//! reload-config = R ; re-read this whole tree without restarting (default: R) //! //! [workon "review.outline"] //! width = 32 @@ -37,8 +43,19 @@ //! [workon "review.diff"] //! layout = split //! zoom = combined +//! text = syntax ; syntax | tint | edit (default: syntax) //! ``` //! +//! ## Live reload (`reload-config`) +//! +//! The whole `workon.review.*` tree is re-readable at runtime — `reload-config` (`R` by +//! default) re-runs [`resolve_runtime`] against the live `.git/config` and swaps in the result, +//! no restart needed. One caveat: `theme = auto`'s terminal-derivation probe never re-runs mid- +//! session (it needs the tty, which the TUI owns once the alternate screen is live, and a second +//! conversation there would corrupt input) — switching `theme` to `dark`/`light` on reload takes +//! effect immediately, but switching back TO `auto` reuses the cached startup probe rather than +//! asking the terminal again. See [`crate::theme::PaletteContext`]'s doc comment. +//! //! ## `icons` //! //! Opt-in nerd-font iconography — top-level next to `theme` (`workon.review.icons`), NOT an @@ -50,6 +67,10 @@ //! [`crate::icons`] for the glyph table. use git2::Repository; +use ratatui::style::Color; + +use crate::keymap::Keymap; +use crate::theme::{self, Palette, PaletteContext, ThemeOverrides}; /// Which view a keybinding or view-setting applies to. /// @@ -117,6 +138,72 @@ pub struct RawViewConfig { pub icons: Option, pub diff_layout: Option, pub diff_zoom: Option, + pub diff_text: Option, +} + +/// Everything `main.rs`'s startup resolution ladder reads out of `workon.review.*`, resolved in +/// one call so a config reload (the `reload-config` action, see +/// [ADR-034](../../../docs/adr/034-review-git-native-config-schema.md)) reproduces startup's +/// resolution exactly rather than duplicating it — guaranteed drift otherwise. `view_config` is left raw +/// (unvalidated): [`crate::app::App::apply_view_config`]/`reload_view_config` are what apply +/// defaults and range-check it, same division [`RawViewConfig`] already documents. +pub struct RuntimeConfig { + pub keymap: Keymap, + pub palette: Palette, + pub view_config: RawViewConfig, + /// `workon.review.theme.*` override warnings, plus unknown-key warnings for the rest of the + /// `workon.review.*` tree (see [`ReviewConfig::unknown_key_warnings`]) — keymap warnings + /// still come off [`Keymap::warnings`], not bundled in here, so a caller that only cares + /// about one doesn't have to pick them back apart. + pub warnings: Vec, +} + +/// Resolve the whole `workon.review.*` tree into a [`RuntimeConfig`], reproducing `main.rs`'s +/// startup ladder exactly: keymap (bindings, defaulting on a read error) → palette (selection → +/// base, `Auto` from `ctx.auto_base` rather than re-probing, `Dark`/`Light` via +/// [`Palette::for_theme`], a read error to [`Palette::dark`]) → `theme.*` overrides applied on +/// top → `ctx.no_color`'s mono override, applied last so it always wins. Every getter here +/// degrades to a default on a config-read error rather than propagating one — the same posture +/// every getter in this module already has, so a reload can never abort mid-session over a +/// transient/malformed `.git/config`. +/// +/// Shared by both the startup resolution (`main.rs`) and a live `reload-config` (`tui.rs`) so the +/// two can never drift apart. +pub fn resolve_runtime(repo: &Repository, ctx: &PaletteContext) -> RuntimeConfig { + let config = ReviewConfig::new(repo); + + let keymap = match config.bindings() { + Ok(bindings) => Keymap::from_bindings(&bindings), + Err(_) => Keymap::defaults(), + }; + + let mut palette = match config.theme() { + Ok(Theme::Auto) => ctx.auto_base.clone(), + Ok(selection) => Palette::for_theme(selection), + Err(_) => Palette::dark(), + }; + + let mut warnings = match config.theme_overrides() { + Ok((overrides, warnings)) => { + palette.apply_overrides(&overrides); + warnings + } + Err(_) => Vec::new(), + }; + if let Ok(unknown) = config.unknown_key_warnings() { + warnings.extend(unknown); + } + + if ctx.no_color { + palette = Palette::mono(theme::is_light_background(palette.background)); + } + + RuntimeConfig { + keymap, + palette, + view_config: config.view_config(), + warnings, + } } /// Decompose a fully-qualified config variable name (as returned by @@ -138,6 +225,153 @@ fn parse_bind_key(name: &str) -> Option<(View, String)> { } } +/// Decode a `workon.review.theme.` key segment (everything after the `theme.` prefix) into +/// a base16 slot index `0..16` (`base00`–`base0f`), or `None` if it isn't a slot key. Git +/// lowercases config variable names, so `key` always arrives lowercase already (`BASE0A` in +/// git config reads back as `base0a`) — no case-folding needed here. +fn slot_index(key: &str) -> Option { + let hex = key.strip_prefix("base")?; + if hex.len() != 2 { + return None; + } + let index = u8::from_str_radix(hex, 16).ok()? as usize; + (index < 16).then_some(index) +} + +/// Resolve a `workon.review.theme.` tint key (kebab-case, mirroring +/// [`crate::theme::Palette`]'s tint field names) to the matching mutable slot on `overrides`, or +/// `None` if `key` isn't a recognized tint key. +fn tint_slot<'a>(overrides: &'a mut ThemeOverrides, key: &str) -> Option<&'a mut Option> { + Some(match key { + "del-line-bg" => &mut overrides.del_line_bg, + "del-edit-bg" => &mut overrides.del_edit_bg, + "add-line-bg" => &mut overrides.add_line_bg, + "add-edit-bg" => &mut overrides.add_edit_bg, + "del-staged-line-bg" => &mut overrides.del_staged_line_bg, + "del-staged-edit-bg" => &mut overrides.del_staged_edit_bg, + "add-staged-line-bg" => &mut overrides.add_staged_line_bg, + "add-staged-edit-bg" => &mut overrides.add_staged_edit_bg, + "add-fg" => &mut overrides.add_fg, + "del-fg" => &mut overrides.del_fg, + "add-staged-fg" => &mut overrides.add_staged_fg, + "del-staged-fg" => &mut overrides.del_staged_fg, + "cursor-bg" => &mut overrides.cursor_bg, + "selection-bg" => &mut overrides.selection_bg, + "cursor-unfocused-bg" => &mut overrides.cursor_unfocused_bg, + "pane-header-focused-fg" => &mut overrides.pane_header_focused_fg, + "filler-fg" => &mut overrides.filler_fg, + _ => return None, + }) +} + +/// The warning message for a `workon.review.theme.` value that didn't parse as a color — +/// shared by the slot and tint branches of [`ReviewConfig::theme_overrides`]. Names no fallback +/// (unlike the view-setting warnings in `App::apply_view_config`): an ignored override has +/// none, the underlying scheme's value stands. +fn invalid_color_warning(key: &str, raw: &str) -> String { + format!( + "workon.review.theme.{key}: invalid color {raw:?} (expected #rrggbb or rrggbb); ignoring" + ) +} + +// ── Unknown-key registry (config validation completeness) ────────────────────────────────── +// +// ADR-034's "Revised (config validation completeness)" section: `theme.*` and bind actions +// already warn on an unrecognized name; every other `workon.review.*` key was read by an +// explicit getter and nothing else, so a typo (`workon.review.diff.laoyut`) was silently +// dropped — no warning, no effect, indistinguishable from a setting that simply did nothing. +// `ReviewConfig::unknown_key_warnings` closes that gap with one pass over +// `entries("workon.review.*")`, driven by [`KNOWN_SCALAR_KEYS`] plus the two open-ended +// subspaces (`theme.`, reusing [`slot_index`]/[`tint_slot`]'s existing key +// lists; `.bind.`, reusing [`parse_bind_key`]'s existing decomposition) — see +// [`is_claimed`]. + +/// Exact `workon.review.` scalar names the registry recognizes — every non-pattern +/// getter's key, suffixed the same way [`ReviewConfig::scalar_key`] builds it. +/// +/// This is the drift-prone half of the registry (the pattern arms can't drift: they reuse +/// [`slot_index`]/[`tint_slot`]/[`parse_bind_key`] directly, so there is nothing to keep in +/// sync). Every getter that reads one of these keys builds its query through +/// [`ReviewConfig::scalar_key`], which `debug_assert!`s the suffix is listed here — so adding +/// a new scalar getter without adding its key to this array doesn't silently drop the key +/// (the pre-existing failure mode this whole pass exists to close); it panics the first time +/// ANY test exercises the new getter, in every debug build (which `cargo test` always is), +/// not just a dedicated registry test. See +/// `scalar_getters_route_every_key_through_the_known_key_registry` below for the explicit +/// enumeration this backstops. +const KNOWN_SCALAR_KEYS: &[&str] = &[ + "theme", + "icons", + "outline.width", + "outline.mode", + "outline.order", + "diff.layout", + "diff.zoom", + "diff.text", +]; + +/// Whether `key` (a `workon.review.` suffix, e.g. `"outline.width"` or `"theme.base00"`) +/// is claimed by some existing part of the schema, so [`ReviewConfig::unknown_key_warnings`] +/// should stay silent on it. `name` is the fully-qualified key, needed for +/// [`parse_bind_key`]'s own prefix-stripping. +/// +/// Claimed does NOT mean valid. `theme.frob` and `diff.bind.made-up-action` are both +/// claimed — their subspace already owns warning about them (`theme_overrides`'s "unknown +/// theme key" warning, `keymap`'s "unknown review keybinding action" warning) — warning again +/// here would double-warn the same typo. Only a shape no subspace recognizes at all (an +/// unlisted scalar name, or a bind typo like `diff.bnid.stage-hunk` that doesn't even parse as +/// a bind entry) falls through to this pass. +fn is_claimed(name: &str, key: &str) -> bool { + KNOWN_SCALAR_KEYS.contains(&key) || key.starts_with("theme.") || parse_bind_key(name).is_some() +} + +/// How close two [`unknown_key_warning`] candidates need to be (by [`levenshtein`] distance) +/// before the nearer one gets suggested — small enough that `laoyut`/`layout` and +/// `thmee`/`theme` (both distance 2) hit, but an unrelated key suggests nothing. +const SUGGESTION_THRESHOLD: usize = 2; + +/// Iterative-DP Levenshtein edit distance between two strings — no new dependency for +/// [`unknown_key_warning`]'s "did you mean" suggestion, which only needs this one comparison. +fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur: Vec = vec![0; b.len() + 1]; + for (i, &ca) in a.iter().enumerate() { + cur[0] = i + 1; + for (j, &cb) in b.iter().enumerate() { + let cost = usize::from(ca != cb); + cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost); + } + std::mem::swap(&mut prev, &mut cur); + } + prev[b.len()] +} + +/// The closest [`KNOWN_SCALAR_KEYS`] entry to `key`, if any entry is within +/// [`SUGGESTION_THRESHOLD`] edits — `None` if the nearest is still too far to plausibly be a +/// typo of a real key. +fn nearest_known_key(key: &str) -> Option<&'static str> { + KNOWN_SCALAR_KEYS + .iter() + .map(|&candidate| (candidate, levenshtein(key, candidate))) + .filter(|&(_, dist)| dist <= SUGGESTION_THRESHOLD) + .min_by_key(|&(_, dist)| dist) + .map(|(candidate, _)| candidate) +} + +/// The warning message for a `workon.review.` name no part of the schema claims — see +/// [`is_claimed`]. Suggests the nearest [`KNOWN_SCALAR_KEYS`] entry when one is close enough +/// ([`nearest_known_key`]) to plausibly be what the user meant. +fn unknown_key_warning(key: &str) -> String { + match nearest_known_key(key) { + Some(suggestion) => { + format!("workon.review.{key}: unknown key, ignoring (did you mean '{suggestion}'?)") + } + None => format!("workon.review.{key}: unknown key, ignoring"), + } +} + /// Configuration reader for `workon.review.*` settings stored in git config. /// /// Mirrors `git-workon-lib`'s `WorkonConfig`: opens the repository's layered config (local > @@ -157,7 +391,7 @@ impl<'repo> ReviewConfig<'repo> { /// unset or unrecognized. pub fn theme(&self) -> Result { let config = self.repo.config()?; - let theme = match config.get_string("workon.review.theme") { + let theme = match config.get_string(&Self::scalar_key("theme")) { Ok(raw) => match raw.as_str() { "dark" => Theme::Dark, "light" => Theme::Light, @@ -168,6 +402,71 @@ 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 + /// 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. + /// + /// Same posture as [`ReviewConfig::bindings`]'s unknown-action handling (no new error + /// types): an unrecognized key under `workon.review.theme.*` or an unparseable color value + /// is collected as a warning and the entry is otherwise ignored, not a hard error. A + /// config-read error collapses to an empty [`ThemeOverrides`] at the call site (`main.rs`), + /// same as every other getter here. + pub fn theme_overrides(&self) -> Result<(ThemeOverrides, Vec), git2::Error> { + let config = self.repo.config()?; + // Same collect-names-then-read-values shape as `bindings()`: `entries()` borrows + // `config` and yields one entry per config LAYER a key is set in, so names are + // deduped first and the precedence-correct value is read via `get_string` afterward. + let mut names: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + { + let mut entries = config.entries(Some("workon.review.theme.*"))?; + while let Some(entry) = entries.next() { + let entry = entry?; + let Ok(name) = entry.name() else { + continue; + }; + if seen.insert(name.to_string()) { + names.push(name.to_string()); + } + } + } + + let mut overrides = ThemeOverrides::default(); + let mut warnings = Vec::new(); + for name in names { + // `workon.review.theme.*` matched a name that isn't `workon.review.theme.` + // (can't happen given the glob above, but keeps this total rather than panicking). + let Some(key) = name.strip_prefix("workon.review.theme.") else { + continue; + }; + let Ok(raw) = config.get_string(&name) else { + continue; + }; + + if let Some(index) = slot_index(key) { + match theme::parse_hex_color(&raw) { + Some(color) => overrides.set_slot(index, color), + None => warnings.push(invalid_color_warning(key, &raw)), + } + continue; + } + + match tint_slot(&mut overrides, key) { + Some(slot) => match theme::parse_hex_color(&raw) { + Some(color) => *slot = Some(color), + None => warnings.push(invalid_color_warning(key, &raw)), + }, + None => warnings.push(format!( + "workon.review.theme.{key}: unknown theme key, ignoring" + )), + } + } + + Ok((overrides, warnings)) + } + /// Read every `workon.review.*.bind.*` (and bare `workon.review.bind.*`) variable, raw and /// unparsed — **one [`RawBinding`] per (view, action)**. `git2`'s `entries()` surfaces the /// same key once per config layer it's set in (a global default AND a local override BOTH @@ -227,7 +526,7 @@ impl<'repo> ReviewConfig<'repo> { /// `theme`, not a view setting: icon mode gates the outline, summary panel, AND winbar. pub fn icons(&self) -> Result, git2::Error> { let config = self.repo.config()?; - match config.get_string("workon.review.icons") { + match config.get_string(&Self::scalar_key("icons")) { Ok(val) => Ok(Some(val)), Err(_) => Ok(None), } @@ -243,6 +542,13 @@ impl<'repo> ReviewConfig<'repo> { 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 + /// 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`], /// 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 @@ -258,9 +564,24 @@ impl<'repo> ReviewConfig<'repo> { 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. + fn scalar_key(suffix: &str) -> String { + debug_assert!( + KNOWN_SCALAR_KEYS.contains(&suffix), + "scalar key {suffix:?} read by a ReviewConfig getter but missing from \ + KNOWN_SCALAR_KEYS (config.rs) — add it there, or the new unknown-key \ + validation pass will warn on a key that actually works" + ); + format!("workon.review.{suffix}") + } + /// Build the `workon.review..` key for a view setting (never a `.bind.` /// entry — [`View::Global`] has no setting namespace, only callers reading `Diff`/`Outline` /// use this). @@ -268,7 +589,7 @@ impl<'repo> ReviewConfig<'repo> { let segment = view .as_key_segment() .expect("view settings are only read for Diff/Outline, never Global"); - format!("workon.review.{segment}.{setting}") + Self::scalar_key(&format!("{segment}.{setting}")) } fn get_view_string(&self, view: View, setting: &str) -> Result, git2::Error> { @@ -286,6 +607,49 @@ impl<'repo> ReviewConfig<'repo> { Err(_) => Ok(None), } } + + /// Warn on every `workon.review.*` key no part of the schema claims — see this module's + /// "Unknown-key registry" section doc for the rationale and [`is_claimed`] for exactly what + /// counts as claimed. Scoped to `workon.review.*` only: `workon.*` at large + /// (`workon.autocopy`, `workon.copyexclude`, …) belongs to `git-workon-lib`, and this crate + /// has no business warning about it. + /// + /// Same dedup concern as [`ReviewConfig::bindings`]/[`ReviewConfig::theme_overrides`]: + /// `entries()` yields one entry per config LAYER a key is set in, so a key set in both local + /// and global config must warn once, not twice — names are deduped before classifying them. + /// A config-read error yields an empty warning list (same degrade-not-abort posture as + /// every other getter here); the caller ([`resolve_runtime`]) already treats that the same + /// as "nothing to warn about". + pub fn unknown_key_warnings(&self) -> Result, git2::Error> { + let config = self.repo.config()?; + let mut names: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + { + let mut entries = config.entries(Some("workon.review.*"))?; + while let Some(entry) = entries.next() { + let entry = entry?; + let Ok(name) = entry.name() else { + continue; + }; + if seen.insert(name.to_string()) { + names.push(name.to_string()); + } + } + } + + let mut warnings = Vec::new(); + for name in names { + // `workon.review.*` matched a name that isn't `workon.review.` (can't happen + // given the glob, but keeps this total rather than panicking). + let Some(key) = name.strip_prefix("workon.review.") else { + continue; + }; + if !is_claimed(&name, key) { + warnings.push(unknown_key_warning(key)); + } + } + Ok(warnings) + } } #[cfg(test)] @@ -443,6 +807,7 @@ mod tests { .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"); let repo = fixture.repo().expect("repo"); @@ -466,6 +831,7 @@ mod tests { config.diff_zoom().expect("zoom"), Some("staged".to_string()) ); + assert_eq!(config.diff_text().expect("text"), Some("tint".to_string())); } #[test] @@ -480,5 +846,518 @@ mod tests { 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); + } + + #[test] + fn theme_overrides_is_empty_when_unset() { + let fixture = FixtureBuilder::new().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let (overrides, warnings) = ReviewConfig::new(repo) + .theme_overrides() + .expect("theme_overrides"); + assert!(overrides.is_empty()); + assert!(warnings.is_empty()); + } + + #[test] + fn theme_overrides_reads_slot_and_tint_keys() { + use crate::theme::Palette; + + let fixture = FixtureBuilder::new() + .config("workon.review.theme.base00", "#101010") + .config("workon.review.theme.cursor-bg", "1a2b3c") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let (overrides, warnings) = ReviewConfig::new(repo) + .theme_overrides() + .expect("theme_overrides"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + assert!(!overrides.is_empty()); + + let mut palette = Palette::dark(); + palette.apply_overrides(&overrides); + assert_eq!(palette.background, Color::Rgb(0x10, 0x10, 0x10)); + assert_eq!(overrides.cursor_bg, Some(Color::Rgb(0x1a, 0x2b, 0x3c))); + } + + #[test] + 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 + // `outline-cursor-unfocused-bg` key with no backward compatibility — see the sibling + // rejection test below for the dropped old key. + let fixture = FixtureBuilder::new() + .config("workon.review.theme.cursor-unfocused-bg", "#1a2b3c") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let (overrides, warnings) = ReviewConfig::new(repo) + .theme_overrides() + .expect("theme_overrides"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + assert_eq!( + overrides.cursor_unfocused_bg, + Some(Color::Rgb(0x1a, 0x2b, 0x3c)) + ); + + let mut palette = Palette::dark(); + palette.apply_overrides(&overrides); + assert_eq!(palette.cursor_unfocused_bg, Color::Rgb(0x1a, 0x2b, 0x3c)); + } + + #[test] + fn theme_overrides_rejects_the_dropped_outline_cursor_unfocused_bg_key() { + // The pre-rename key must NOT resolve as a compat alias — it's just an unknown key now, + // warned and ignored exactly like any other unrecognized `workon.review.theme.*` name. + let fixture = FixtureBuilder::new() + .config("workon.review.theme.outline-cursor-unfocused-bg", "#1a2b3c") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let (overrides, warnings) = ReviewConfig::new(repo) + .theme_overrides() + .expect("theme_overrides"); + assert!(overrides.is_empty(), "dropped key must not set any field"); + assert_eq!(warnings.len(), 1, "got: {warnings:?}"); + assert!(warnings[0].contains("outline-cursor-unfocused-bg")); + } + + #[test] + fn theme_overrides_reads_the_pane_header_focused_fg_tint_key() { + use crate::theme::Palette; + + let fixture = FixtureBuilder::new() + .config("workon.review.theme.pane-header-focused-fg", "#c0ffee") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let (overrides, warnings) = ReviewConfig::new(repo) + .theme_overrides() + .expect("theme_overrides"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + assert_eq!( + overrides.pane_header_focused_fg, + Some(Color::Rgb(0xc0, 0xff, 0xee)) + ); + + let mut palette = Palette::dark(); + palette.apply_overrides(&overrides); + assert_eq!(palette.pane_header_focused_fg, Color::Rgb(0xc0, 0xff, 0xee)); + } + + #[test] + fn theme_overrides_reads_the_filler_fg_tint_key() { + use crate::theme::Palette; + + let fixture = FixtureBuilder::new() + .config("workon.review.theme.filler-fg", "#403a48") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let (overrides, warnings) = ReviewConfig::new(repo) + .theme_overrides() + .expect("theme_overrides"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + assert_eq!(overrides.filler_fg, Some(Color::Rgb(0x40, 0x3a, 0x48))); + + let mut palette = Palette::dark(); + palette.apply_overrides(&overrides); + assert_eq!(palette.filler_fg, Color::Rgb(0x40, 0x3a, 0x48)); + } + + #[test] + fn theme_overrides_slot_keys_are_case_insensitive() { + use crate::theme::Palette; + + // Git lowercases config variable names on write, so `BASE0A` in the fixture's config + // arrives back as `base0a` — this proves the whole path (fixture write → git2 read → + // our slot_index) lands in slot 10 (base0A → warn_fg), not that our own code + // case-folds anything. + let fixture = FixtureBuilder::new() + .config("workon.review.theme.BASE0A", "#c1c1c1") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let (overrides, warnings) = ReviewConfig::new(repo) + .theme_overrides() + .expect("theme_overrides"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let mut palette = Palette::dark(); + palette.apply_overrides(&overrides); + assert_eq!(palette.warn_fg, Color::Rgb(0xc1, 0xc1, 0xc1)); + } + + #[test] + fn theme_overrides_warns_and_ignores_an_invalid_hex_value() { + let fixture = FixtureBuilder::new() + .config("workon.review.theme.base00", "not-a-color") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let (overrides, warnings) = ReviewConfig::new(repo) + .theme_overrides() + .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. + assert_eq!( + warnings[0], + "workon.review.theme.base00: invalid color \"not-a-color\" \ + (expected #rrggbb or rrggbb); ignoring" + ); + } + + #[test] + fn theme_overrides_warns_on_unknown_keys() { + let fixture = FixtureBuilder::new() + .config("workon.review.theme.base10", "#101010") // no slot 16 + .config("workon.review.theme.cursorbg", "#101010") // misspelled tint key + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let (overrides, warnings) = ReviewConfig::new(repo) + .theme_overrides() + .expect("theme_overrides"); + assert!(overrides.is_empty()); + assert_eq!(warnings.len(), 2, "got: {warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("base10"))); + assert!(warnings.iter().any(|w| w.contains("cursorbg"))); + } + + // ── `resolve_runtime` (the shared startup/reload structural core) ────────── + + fn auto_ctx() -> PaletteContext { + PaletteContext { + auto_base: Palette::dark(), + no_color: false, + } + } + + #[test] + fn resolve_runtime_applies_theme_overrides_on_top_of_the_auto_base_for_theme_auto() { + let fixture = FixtureBuilder::new() + .config("workon.review.theme.base00", "#101010") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let runtime = resolve_runtime(repo, &auto_ctx()); + assert_eq!(runtime.palette.background, Color::Rgb(0x10, 0x10, 0x10)); + // Every other field still traces back to `auto_base` (`Palette::dark()`), not some other + // base — spot-check one untouched field. + assert_eq!(runtime.palette.dim, Palette::dark().dim); + assert!(runtime.warnings.is_empty()); + } + + #[test] + fn resolve_runtime_honors_dark_and_light_selection() { + let fixture = FixtureBuilder::new() + .config("workon.review.theme", "light") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + // A distinctive `auto_base` proves `theme = light` ignores it entirely, rather than + // falling through to the cached probe base. + let runtime = resolve_runtime( + repo, + &PaletteContext { + auto_base: Palette::mono(false), + no_color: false, + }, + ); + assert_eq!(runtime.palette.background, Palette::light().background); + } + + #[test] + fn resolve_runtime_no_color_yields_a_mono_palette_no_override_can_recolor() { + let fixture = FixtureBuilder::new() + .config("workon.review.theme.base00", "#101010") + .config("workon.review.theme.cursor-bg", "#1a2b3c") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let runtime = resolve_runtime( + repo, + &PaletteContext { + auto_base: Palette::dark(), + no_color: true, + }, + ); + assert!( + runtime.palette.colorless, + "NO_COLOR must win over any override" + ); + assert_ne!( + runtime.palette.background, + Color::Rgb(0x10, 0x10, 0x10), + "the base00 override must not survive the mono substitution" + ); + assert_ne!( + runtime.palette.cursor_bg, + Color::Rgb(0x1a, 0x2b, 0x3c), + "the cursor-bg override must not survive the mono substitution" + ); + } + + #[test] + fn resolve_runtime_degrades_on_a_config_read_error_instead_of_panicking() { + // Corrupt `.git/config` with unparseable syntax so every `repo.config()?` call inside + // `resolve_runtime`'s ladder fails — the degrade-not-abort posture every getter in this + // module already has (see the module doc comment), exercised end-to-end here rather than + // per-getter. + let fixture = FixtureBuilder::new().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let cfg_path = repo.path().join("config"); + std::fs::write(&cfg_path, "[this is not valid gitconfig\n").expect("corrupt config"); + + let runtime = resolve_runtime(repo, &auto_ctx()); + assert!( + runtime.keymap.warnings().is_empty(), + "defaults, not warnings" + ); + assert_eq!(runtime.palette.background, Palette::dark().background); + assert!(runtime.warnings.is_empty()); + assert_eq!(runtime.view_config, RawViewConfig::default()); + } + + #[test] + fn theme_overrides_coexists_with_the_theme_selection() { + // `[workon "review"] theme = dark` and `[workon "review.theme"] base00 = …` are + // different subsections — both must read fine, independently. + let fixture = FixtureBuilder::new() + .config("workon.review.theme", "dark") + .config("workon.review.theme.base00", "#101010") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let config = ReviewConfig::new(repo); + + assert_eq!(config.theme().expect("theme"), Theme::Dark); + let (overrides, warnings) = config.theme_overrides().expect("theme_overrides"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + assert!(!overrides.is_empty()); + } + + // ── unknown-key registry (config validation completeness) ────────────────── + + #[test] + fn unknown_key_warnings_is_empty_when_unset() { + let fixture = FixtureBuilder::new().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + assert!(ReviewConfig::new(repo) + .unknown_key_warnings() + .expect("unknown_key_warnings") + .is_empty()); + } + + #[test] + fn unknown_key_warnings_flags_a_typo_in_a_scalar_key() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.laoyut", "split") + .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, + vec![ + "workon.review.diff.laoyut: unknown key, ignoring (did you mean 'diff.layout'?)" + .to_string() + ] + ); + } + + #[test] + fn unknown_key_warnings_suggests_nothing_for_an_unrelated_key() { + let fixture = FixtureBuilder::new() + .config("workon.review.zzzzzzzz", "1") + .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, + vec!["workon.review.zzzzzzzz: unknown key, ignoring".to_string()] + ); + } + + #[test] + fn unknown_key_warnings_stays_silent_on_an_unrecognized_theme_key() { + // `theme.*` unknown keys already warn in `theme_overrides` — the registry pass must + // treat the whole `theme.*` shape as claimed, or a bad theme key double-warns. + let fixture = FixtureBuilder::new() + .config("workon.review.theme.cursorbg", "#101010") // misspelled tint key + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let config = ReviewConfig::new(repo); + + assert!(config + .unknown_key_warnings() + .expect("unknown_key_warnings") + .is_empty()); + let (_, theme_warnings) = config.theme_overrides().expect("theme_overrides"); + assert_eq!( + theme_warnings.len(), + 1, + "theme_overrides should still be the one place that warns: {theme_warnings:?}" + ); + } + + #[test] + fn unknown_key_warnings_stays_silent_on_an_unrecognized_bind_action() { + // Unknown bind ACTIONS already warn in `keymap` — the registry pass only cares that + // the shape parses as a bind entry (`parse_bind_key`), not that the action is real. + let fixture = FixtureBuilder::new() + .config("workon.review.diff.bind.made-up-action", "x") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + assert!(ReviewConfig::new(repo) + .unknown_key_warnings() + .expect("unknown_key_warnings") + .is_empty()); + } + + #[test] + fn unknown_key_warnings_flags_a_malformed_bind_shape() { + // A typo of `bind` itself (`bnid`) doesn't parse as a bind entry at all — it's an + // unknown key, not a bind-action problem, and `keymap` never sees it (it only iterates + // `ReviewConfig::bindings()`, which never yields this entry). + let fixture = FixtureBuilder::new() + .config("workon.review.diff.bnid.stage-hunk", "s") + .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.bnid.stage-hunk")); + } + + #[test] + fn unknown_key_warnings_dedups_a_key_set_in_multiple_layers() { + // Same layering concern as `bindings_dedups_a_key_set_in_multiple_layers_to_the_ + // winning_value`: `entries()` yields one entry per config LAYER, not one per key. + let fixture = FixtureBuilder::new() + .config("workon.review.bogus-key", "a") + .config("workon.review.bogus-key", "b") + .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, + "one warning per key, not one per config layer; got {warnings:?}" + ); + } + + #[test] + fn unknown_key_warnings_is_empty_for_a_fixture_setting_one_of_every_documented_key() { + // The false-positive gate: a validation pass that cries wolf on working config is worse + // than no validation at all. Sets every KNOWN_SCALAR_KEYS entry, a theme slot, a theme + // tint, a global bind, and a per-view bind — none of it should warn. + let fixture = FixtureBuilder::new() + .config("workon.review.theme", "dark") + .config("workon.review.icons", "nerd") + .config("workon.review.outline.width", "40") + .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 + .config("workon.review.bind.quit", "q esc") // a global bind + .config("workon.review.diff.bind.stage-hunk", "s x") // a per-view bind + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let warnings = ReviewConfig::new(repo) + .unknown_key_warnings() + .expect("unknown_key_warnings"); + 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 + /// 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 + /// `scalar_key` `debug_assert!` backs this up structurally: even a getter this list forgets + /// to enumerate would panic the moment ANY test exercises it, not just this one. + #[test] + fn scalar_getters_route_every_key_through_the_known_key_registry() { + let fixture = FixtureBuilder::new() + .config("workon.review.theme", "dark") + .config("workon.review.icons", "nerd") + .config("workon.review.outline.width", "40") + .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"); + let repo = fixture.repo().expect("repo"); + let config = ReviewConfig::new(repo); + + let probes: Vec<(&str, bool)> = vec![ + ("theme", config.theme().is_ok()), + ("icons", config.icons().expect("icons").is_some()), + ( + "outline.width", + config.outline_width().expect("width").is_some(), + ), + ( + "outline.mode", + config.outline_mode().expect("mode").is_some(), + ), + ( + "outline.order", + config.outline_order().expect("order").is_some(), + ), + ( + "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()), + ]; + + for (key, getter_saw_it) in &probes { + assert!( + *getter_saw_it, + "getter for {key:?} did not read its own fixture value" + ); + assert!( + KNOWN_SCALAR_KEYS.contains(key), + "{key:?} is read by a getter but missing from KNOWN_SCALAR_KEYS — it would \ + warn as unknown while working correctly" + ); + } + assert_eq!( + probes.len(), + KNOWN_SCALAR_KEYS.len(), + "a scalar getter was added without a matching probe above (or vice versa) — \ + update this list alongside KNOWN_SCALAR_KEYS" + ); } } diff --git a/git-workon-review/src/icons.rs b/git-workon-review/src/icons.rs index aa68c6c6..086a9aea 100644 --- a/git-workon-review/src/icons.rs +++ b/git-workon-review/src/icons.rs @@ -24,7 +24,8 @@ use ratatui::style::Color; /// Which iconography strategy is active TUI-wide — `workon.review.icons` (`nerd`/`none`), /// read once at startup by `App::apply_view_config` (`RawViewConfig` field -> `ReviewConfig` -/// getter -> `parse_icon_mode` -> warn-and-fallback in `apply_view_config` -> `App` field). +/// getter -> `resolve_option` (against `ICON_MODE_OPTIONS`) -> warn-and-fallback in +/// `apply_view_config` -> `App` field). /// Top-level like the theme, not an outline setting: it gates the outline's file/dir icons, /// the summary panel's glyphs, and the winbar's marker/diffstat/file icons alike. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index d42b803f..e11b0f78 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -14,7 +14,9 @@ //! - [`Keymap`] — resolves the registry defaults against a repo's [`RawBinding`]s (a git entry //! overrides that action's default; an empty value unbinds), builds per-view //! sequence→command lookup lists, and drives dispatch through [`Keymap::advance`]. Unknown -//! action names and same-view key collisions are collected as [`Keymap::warnings`]. +//! action names, same-view key collisions, and prefix clashes (a complete binding that's a +//! strict prefix of another, leaving the shorter one unreachable) are collected as +//! [`Keymap::warnings`]. //! //! **Not handled here** (stays hardcoded in `tui.rs`): the confirm modal (`y`/`n`/`Esc`) and the //! whole `Esc`-precedence cascade (confirm > help > selection-cancel > outline-focused-quit > @@ -41,6 +43,7 @@ pub enum Command { Quit, ToggleOutline, ToggleHelp, + ReloadConfig, // Diff view. CursorDown, CursorUp, @@ -65,6 +68,8 @@ pub enum Command { PrevChangeset, ExpandGap, ExpandGapAll, + ResetGaps, + ExpandAllGaps, HscrollLeft, HscrollRight, // Diff view. @@ -129,6 +134,13 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "?", description: "Toggle the help overlay", }, + Registered { + command: Command::ReloadConfig, + view: View::Global, + name: "reload-config", + default_keys: "R", + description: "Reload config (theme, keys, view settings)", + }, // ── Diff view ──────────────────────────────────────────────────────────── Registered { command: Command::CursorDown, @@ -183,7 +195,12 @@ pub static REGISTRY: &[Registered] = &[ command: Command::CycleZoom, view: View::Diff, name: "cycle-zoom", - default_keys: "z", + // 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 + // mechanics; `build_context`'s prefix-clash check would now warn on this, not just + // silently break dispatch). `Z` was free in `View::Diff`. + default_keys: "Z", description: "Cycle the staged/unstaged zoom", }, Registered { @@ -253,14 +270,14 @@ pub static REGISTRY: &[Registered] = &[ command: Command::NextHunk, view: View::Diff, name: "next-hunk", - default_keys: "]h", + default_keys: "]h n", description: "Go to the next hunk", }, Registered { command: Command::PrevHunk, view: View::Diff, name: "prev-hunk", - default_keys: "[h", + default_keys: "[h p", description: "Go to the previous hunk", }, Registered { @@ -312,6 +329,20 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "E", description: "Reveal the whole collapsed gap under the cursor", }, + Registered { + command: Command::ResetGaps, + view: View::Diff, + name: "reset-gaps", + default_keys: "zM", + description: "Collapse all gaps back to the initial view", + }, + Registered { + command: Command::ExpandAllGaps, + view: View::Diff, + name: "expand-all-gaps", + default_keys: "zR", + description: "Reveal every collapsed gap in the file", + }, // ── Outline view ───────────────────────────────────────────────────────── Registered { command: Command::OutlineDown, @@ -717,7 +748,7 @@ impl Keymap { let mut exact: Option = None; let mut has_prefix = false; for (seq, command) in list { - if seq.len() > buffer.len() && seq[..buffer.len()] == *buffer { + if is_strict_prefix(buffer, seq) { has_prefix = true; } else if seq.as_slice() == buffer { exact.get_or_insert(*command); @@ -735,7 +766,13 @@ impl Keymap { /// Build one context's active binding list: every global row plus every row of `view`, in /// registry order (global first). On a key sequence already claimed in this context, the -/// first-seen (registry-order) command wins and a collision warning is recorded. +/// first-seen (registry-order) command wins and a collision warning is recorded. Also flags +/// **prefix clashes**: a complete binding that is a strict prefix of another complete binding in +/// the same context. `match_keys` gives a pending (longer) chord precedence over an exact +/// (shorter) match in the same scan, so the shorter binding's command can never fire — the +/// warning names both actions and the view. Two chords merely sharing a prefix with each other +/// (`zM` vs `zR`) are NOT a clash: neither is a strict prefix of the other since a chord anchor +/// key (`z`) is never itself a complete binding here. fn build_context( resolved: &[Vec], view: View, @@ -757,6 +794,25 @@ fn build_context( command_label(*winner), )); } else { + for (existing, other) in &out { + if is_strict_prefix(existing, seq) { + warnings.push(prefix_clash_warning( + view, + existing, + *other, + seq, + entry.command, + )); + } else if is_strict_prefix(seq, existing) { + warnings.push(prefix_clash_warning( + view, + seq, + entry.command, + existing, + *other, + )); + } + } out.push((seq.clone(), entry.command)); } } @@ -764,6 +820,34 @@ fn build_context( out } +/// True when `shorter` is strictly shorter than `longer` AND is its leading sub-sequence. +/// The ONE definition of chord-prefix precedence: `match_keys`' pending test and +/// `build_context`'s clash warning both call this, so the "the shorter binding can never +/// fire" claim in [`prefix_clash_warning`] can't drift from what dispatch actually does. +fn is_strict_prefix(shorter: &[KeyPress], longer: &[KeyPress]) -> bool { + shorter.len() < longer.len() && longer[..shorter.len()] == *shorter +} + +/// A prefix-clash warning: `shorter_seq`/`shorter_cmd` is the bare(r) binding rendered +/// unreachable by the longer, chord-taking-precedence `longer_seq`/`longer_cmd`. +fn prefix_clash_warning( + view: View, + shorter_seq: &[KeyPress], + shorter_cmd: Command, + longer_seq: &[KeyPress], + longer_cmd: Command, +) -> String { + format!( + "key '{}' for {} is unreachable in the {} view: the longer chord '{}' is bound to {}, \ + and a pending chord always takes precedence over a shorter binding sharing its prefix", + render_seq(shorter_seq), + command_label(shorter_cmd), + view_label(view), + render_seq(longer_seq), + command_label(longer_cmd), + ) +} + /// One row of the `?` help overlay: an action's resolved key label (space-joined alternatives, /// e.g. `"tab ]f"`) and its registry description. Built by [`help_sections`]. #[derive(Debug, Clone, PartialEq, Eq)] @@ -828,8 +912,10 @@ fn view_label_title(view: View) -> &'static str { } /// The resolved key label for the FIRST alternative bound to `command` (for the curated footer -/// hint, which only has room for one key per action), or `None` when unbound. -fn primary_key(keymap: &Keymap, command: Command) -> Option { +/// hint, which only has room for one key per action), or `None` when unbound. `pub` (not +/// `pub(crate)`): `main.rs::seat_app` — the bin target, consuming the lib externally — uses the +/// same first-alternative policy for the refusal hint's zoom key label, so the two can't diverge. +pub fn primary_key(keymap: &Keymap, command: Command) -> Option { keymap.keys_for(command).first().map(|seq| render_seq(seq)) } @@ -1170,6 +1256,87 @@ mod tests { ); } + /// CS3 (diff-fold-keys): `n`/`p` are extra default bindings on the existing hunk-nav + /// commands (`]h`/`[h`), added purely for symmetry with the outline's `n`/`p` changeset nav. + /// `primary_key` still picks the first token, so the footer/help keep showing `]h`/`[h` — + /// `next-hunk`/`prev-hunk` aren't in `DIFF_HINTS` today, but `primary_key`/`keys_for` (which + /// the help overlay uses) are exercised by `footer_hint_renders_the_curated_diff_entries` and + /// `help_sections_groups_global_and_the_focused_view_only`. + #[test] + fn n_and_p_dispatch_diff_hunk_nav_with_no_collisions() { + let km = Keymap::defaults(); + assert!( + km.warnings().is_empty(), + "n/p hunk-nav defaults must not collide with anything: {:?}", + km.warnings() + ); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('n'))]), + Dispatch::Command(Command::NextHunk) + ); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('p'))]), + Dispatch::Command(Command::PrevHunk) + ); + } + + /// 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. + /// (`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() { + let km = Keymap::defaults(); + assert!( + km.warnings().is_empty(), + "cycle-zoom's rebind to Z must not collide with anything: {:?}", + km.warnings() + ); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('Z'))]), + Dispatch::Command(Command::CycleZoom) + ); + } + + /// `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. + #[test] + fn z_m_and_z_r_dispatch_diff_gap_fold_all_with_no_collisions() { + let km = Keymap::defaults(); + assert!( + km.warnings().is_empty(), + "zM/zR defaults must not collide with anything: {:?}", + km.warnings() + ); + assert_eq!( + feed( + &km, + false, + &[key(KeyCode::Char('z')), key(KeyCode::Char('M'))] + ), + Dispatch::Command(Command::ResetGaps) + ); + assert_eq!( + feed( + &km, + false, + &[key(KeyCode::Char('z')), key(KeyCode::Char('R'))] + ), + Dispatch::Command(Command::ExpandAllGaps) + ); + } + #[test] fn a_config_rebind_overrides_the_default() { let km = Keymap::from_bindings(&[RawBinding { @@ -1208,6 +1375,46 @@ mod tests { ); } + #[test] + fn reload_config_is_registered_global_with_default_shift_r() { + let km = Keymap::defaults(); + assert!(km.warnings().is_empty()); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('R'))]), + Dispatch::Command(Command::ReloadConfig) + ); + // Global — fires the same whether the outline or the diff has focus. + assert_eq!( + feed(&km, true, &[key(KeyCode::Char('R'))]), + Dispatch::Command(Command::ReloadConfig) + ); + } + + #[test] + fn reload_config_is_rebindable_via_workon_review_bind() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Global, + action: "reload-config".to_string(), + keys: "ctrl-r".to_string(), + }]); + assert!(km.warnings().is_empty()); + assert_eq!( + feed( + &km, + false, + &[KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)] + ), + Dispatch::Command(Command::ReloadConfig) + ); + // The old default is now unbound. + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('R'))]), + Dispatch::Unmatched { + mid_sequence: false + } + ); + } + #[test] fn an_unknown_action_warns_without_panicking() { let km = Keymap::from_bindings(&[RawBinding { @@ -1252,6 +1459,34 @@ 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 + // 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. + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "cycle-zoom".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("diff"))); + assert!(km.warnings().iter().any(|w| w.contains("reset-gaps"))); + assert!(km.warnings().iter().any(|w| w.contains("expand-all-gaps"))); + } + + #[test] + fn z_m_and_z_r_alone_do_not_clash_with_each_other() { + // zM/zR both anchor on `z` but neither is a strict prefix of the other — no warning. + let km = Keymap::defaults(); + assert!( + km.warnings().is_empty(), + "zM/zR must not warn about each other: {:?}", + km.warnings() + ); + } + // ── Dispatch / sequences ────────────────────────────────────────────────── #[test] diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 40ca46f5..1789f241 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -1,5 +1,7 @@ mod tui; +use std::ffi::OsStr; + use clap::{CommandFactory, Parser}; use clap_complete::engine::ArgValueCompleter; use clap_complete::env::CompleteEnv; @@ -8,10 +10,20 @@ 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::Keymap; +use workon_review::keymap::{self, Command, Keymap}; use workon_review::source::{complete_source, resolve_source, Source}; use workon_review::terminal_query; -use workon_review::theme::Palette; +use workon_review::theme::{self, Palette}; + +/// Whether `NO_COLOR` (per `no-color.org`) requests colorless output — any non-empty value +/// means yes, unset or empty means no. `FORCE_COLOR` is deliberately not consulted: `NO_COLOR` +/// is the user's explicit request for THIS tool's colors, whereas `FORCE_COLOR` (already read +/// elsewhere for test/output-capture posture) answers a different question. Takes `Option<&OsStr>` +/// rather than reading `std::env::var_os` itself so tests can drive it without touching process +/// env (the `FORCE_COLOR=3` dev-env trap this repo's tests already work around). +fn no_color(var: Option<&OsStr>) -> bool { + var.is_some_and(|v| !v.is_empty()) +} /// A TUI for reviewing changesets #[derive(Debug, Parser)] @@ -81,36 +93,57 @@ fn main() -> Result<()> { return Ok(()); } - // Resolve the keymap from git config once at startup, BEFORE `repo` moves into `App` - // (ADR-034). A failed config read degrades to the registry defaults rather than aborting the - // review. Collision/unknown-action warnings surface through the footer notice below. - let keymap = match ReviewConfig::new(&repo).bindings() { - Ok(bindings) => Keymap::from_bindings(&bindings), - Err(_) => Keymap::defaults(), - }; - - // Resolve the palette selection the same way, 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 hard timeout and always yields a curated fallback on a silent/hostile terminal, - // never a hang. `Dark`/`Light` stay CS5's I/O-free `for_theme` path. - // `probed` is whether a real probe conversation happened on the tty this launch — NOT just - // "theme was auto". `detect_auto_palette` reports `false` on a cached "silent terminal" - // verdict (see `probe_cache`), since a cache hit writes nothing to the tty and so owes no - // flush; every other path (an answered probe, a timed-out-uncached probe, a non-auto theme) - // is `false`/`true` exactly as before. + // 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 + // 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 + // a cheap unread placeholder here rather than running `for_theme` a second time only to have + // `resolve_runtime` immediately re-derive and use its own. `probed` is whether a real probe + // conversation happened on the tty this launch — NOT just "theme was auto". `detect_auto_ + // palette` reports `false` on a cached "silent terminal" verdict (see `probe_cache`), since a + // cache hit writes nothing to the tty and so owes no flush; every other path (an answered + // probe, a timed-out-uncached probe, a non-auto theme) is `false`/`true` exactly as before. let selection = ReviewConfig::new(&repo).theme(); - let (theme, probed) = match selection { + let (auto_base, probed) = match selection { Ok(config::Theme::Auto) => terminal_query::detect_auto_palette(), - Ok(selection) => (Palette::for_theme(selection), false), - Err(_) => (Palette::dark(), false), + _ => (Palette::dark(), false), }; - // Resolve the view-config settings (outline width/mode, diff layout/zoom) the same way, - // before `repo` moves — CS7. `view_config` reads into an owned `RawViewConfig`, so no - // borrow of `repo` survives past this statement (unlike a bare `ReviewConfig<'repo>`, which - // would still be borrowing `repo` when `App::from_changesets` tries to move it below). - let view_config = ReviewConfig::new(&repo).view_config(); + // 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. + // `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 { + // Crossterm ALSO honors NO_COLOR, by stripping every color SGR at the output layer — + // which would erase `mono()`'s achromatic washes and leave cursor/selection/staged + // attribution invisible (the exact unusability the grayscale ladders exist to prevent). + // This app owns NO_COLOR semantics at the palette level instead, so disable crossterm's + // blanket suppression and let the grayscale washes through. One-time: `resolve_runtime` + // itself has no terminal to reconfigure, so this stays here rather than moving with it. + crossterm::style::force_color_output(true); + } + + // `PaletteContext` bundles what `resolve_runtime` can't derive itself (it's pure/I/O-free): the + // probe result (or the non-auto/error base) to use whenever `theme = auto`, never re-probed, + // and the NO_COLOR kill-switch. Reused verbatim by a later `reload-config` (ADR-034) so `auto` + // stays cached across the session — see `PaletteContext`'s doc comment. + let palette_ctx = theme::PaletteContext { + auto_base, + no_color: no_color_env, + }; + + // Resolve the keymap, palette, and view-config settings in one call, BEFORE `repo` moves into + // `App` — the same structural core a config reload uses (see `config::resolve_runtime`'s doc + // comment), so startup and reload can never drift apart. Every getter degrades to a default on + // a config-read error rather than aborting the review (ADR-034); collision/unknown-action/ + // malformed-override warnings surface through the footer notice below. + let runtime = config::resolve_runtime(&repo, &palette_ctx); + let keymap = runtime.keymap; + let theme = runtime.palette; + let theme_override_warnings = runtime.warnings; + let view_config = runtime.view_config; // After a probe, OSC replies from a slow terminal (e.g. one ssh round-trip away) may have // straggled in while the theme was being derived above. Discard them now, BEFORE crossterm @@ -177,12 +210,19 @@ 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). - let mut app = seat_app(repo, views, source, &view_config, &keymap); + let mut app = seat_app( + repo, + views, + source, + &view_config, + &keymap, + &theme_override_warnings, + ); // 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. tui.into_diagnostic()? - .run(&mut app, &keymap, &theme, repo_path) + .run(&mut app, keymap, theme, repo_path, &palette_ctx) .into_diagnostic()?; } else { // Every changeset starts `Pending` (ADR-037's "Slots") — `App` is constructible from @@ -195,10 +235,17 @@ fn main() -> Result<()> { .map(ChangesetView::pending) .collect(); - let mut app = seat_app(repo, views, source, &view_config, &keymap); + let mut app = seat_app( + repo, + views, + source, + &view_config, + &keymap, + &theme_override_warnings, + ); tui.into_diagnostic()? - .run_streamed(&mut app, &keymap, &theme, repo_path, changesets) + .run_streamed(&mut app, keymap, theme, repo_path, changesets, &palette_ctx) .into_diagnostic()?; } @@ -207,16 +254,17 @@ 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 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. +/// 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( repo: Repository, views: Vec, source: Option, view_config: &config::RawViewConfig, keymap: &Keymap, + theme_override_warnings: &[String], ) -> App { let mut app = App::from_changesets(repo, views); if let Some(source) = source { @@ -235,14 +283,61 @@ fn seat_app( let view_config_warnings = app.apply_view_config(view_config); app.open_current(); - // A misconfigured keybinding or view-config setting 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. + // 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. + 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); + + 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` +/// (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); + } let mut warnings = keymap.warnings().to_vec(); - warnings.extend(view_config_warnings); - if !warnings.is_empty() { + warnings.extend(extra_warnings); + let had_warnings = !warnings.is_empty(); + if had_warnings { app.notify(warnings.join("; "), Severity::Error); } + had_warnings +} - app +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_color_truth_table() { + assert!(!no_color(None), "unset must not trigger mono"); + assert!( + !no_color(Some(OsStr::new(""))), + "empty must not trigger mono" + ); + assert!(no_color(Some(OsStr::new("1")))); + assert!( + no_color(Some(OsStr::new("0"))), + "any non-empty value counts, per no-color.org" + ); + assert!(no_color(Some(OsStr::new("true")))); + } } diff --git a/git-workon-review/src/ops.rs b/git-workon-review/src/ops.rs index 3f226326..fbb536ab 100644 --- a/git-workon-review/src/ops.rs +++ b/git-workon-review/src/ops.rs @@ -9,16 +9,21 @@ //! - [`FileStatus::Modified`]/[`FileStatus::Renamed`]/[`FileStatus::Copied`], non-binary: a //! hunk patch can express both a preimage and a postimage, so `apply_hunk`/`apply_lines` //! synthesize one and hand it to the `Applier`. -//! - Everything else ([`FileStatus::Added`]/[`FileStatus::Deleted`]/[`FileStatus::Untracked`]/ -//! [`FileStatus::Unmerged`], or a binary file of any status): there is no two-sided hunk to -//! patch — a hunk of one of these files IS the whole file. `apply_hunk` falls back to the -//! file-level op for the verb. `apply_lines` does NOT fall back: line selection on a -//! whole-file change is a different operation the caller asked for by mistake, so it must -//! REFUSE with a typed error rather than silently widen the selection to "the whole file" -//! behind the caller's back. The cleanest way to get that refusal is to call +//! - [`FileStatus::Deleted`]/[`FileStatus::Unmerged`], or a binary file of any status: there is +//! no two-sided hunk to patch — a hunk of one of these files IS the whole file. `apply_hunk` +//! falls back to the file-level op for the verb. `apply_lines` does NOT fall back: line +//! selection on a whole-file change is a different operation the caller asked for by mistake, +//! so it must REFUSE with a typed error rather than silently widen the selection to "the whole +//! file" behind the caller's back. The cleanest way to get that refusal is to call //! `partial_hunk_patch` unconditionally and propagate its `Result` — it already contains //! exactly this guard (see `synthesis.rs`), so `apply_lines` doesn't duplicate the status //! check. +//! - [`FileStatus::Untracked`]/[`FileStatus::Added`], non-binary: no `HEAD`/index preimage +//! exists, but `partial_hunk_patch` synthesizes a one-sided (creation) patch for these — line +//! ops ARE supported ([`supports_line_ops`]), just not through `apply_hunk`'s whole-hunk path: +//! `is_hunk_patchable` (and therefore `apply_hunk`'s routing) is UNCHANGED for these statuses, +//! since a hunk-level `s`/`d` on one of these files still means "the whole file", not "the +//! whole hunk" — there's nothing hunk-shaped left once you're not slicing by line. use git2::Repository; @@ -44,6 +49,19 @@ pub fn is_hunk_patchable(file: &FileChange) -> bool { ) } +/// Whether `file` supports LINE-precise stage/discard — the gate `App::stage_selection`/ +/// `App::discard_selection` (m4-staging) check before offering a line selection, per the +/// line-ops-on-one-sided-files handoff. Broader than [`is_hunk_patchable`]: a non-binary +/// `Untracked`/`Added` file has no two-sided hunk (so hunk-LEVEL `s`/`d` still falls back to the +/// whole file, unchanged — see this module's doc comment), but `partial_hunk_patch` CAN +/// synthesize a one-sided (creation) patch from a selection of its lines, so line ops on it are +/// not a refusal. Deliberately does NOT touch [`is_hunk_patchable`] itself: that predicate has +/// other callers (hunk routing, zoom gating) whose semantics must not change. +pub fn supports_line_ops(file: &FileChange) -> bool { + is_hunk_patchable(file) + || (!file.is_binary && matches!(file.status, FileStatus::Untracked | FileStatus::Added)) +} + /// Apply `verb` to the WHOLE of `file`'s hunk at `hunk_idx`. /// /// Routes through patch synthesis when the file is hunk-patchable; otherwise falls back to the diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 3a34f5de..75584b0c 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -59,7 +59,7 @@ impl OutlineMode { } /// The kebab-cased display name (CS4, `outline-mode-cycle`) — used by the footer's `i - /// →` hint and mirrors `App::parse_outline_mode`'s config strings (`app.rs`), so the + /// →` hint and mirrors `OUTLINE_MODE_OPTIONS`'s config strings (`app.rs`), so the /// two never drift apart. pub fn label(self) -> &'static str { match self { diff --git a/git-workon-review/src/probe_cache.rs b/git-workon-review/src/probe_cache.rs index 46b8d4b8..94a48a17 100644 --- a/git-workon-review/src/probe_cache.rs +++ b/git-workon-review/src/probe_cache.rs @@ -15,10 +15,14 @@ //! working every launch. //! //! ## Key -//! The controlling tty's device path (`/dev/ttysNNN` via `ttyname_r`) plus `$TERM` and -//! `$TERM_PROGRAM`. The tty path scopes a verdict to one terminal window; TERM/TERM_PROGRAM guard -//! against a later, DIFFERENT emulator reusing a recycled tty number and inheriting a stale -//! "silent" verdict it never earned. +//! The controlling tty's CONCRETE device path (`/dev/ttysNNN` on macOS, `/dev/pts/N` on Linux, +//! via `ttyname_r` on the first standard fd that is a tty) plus `$TERM` and `$TERM_PROGRAM`. The +//! tty path scopes a verdict to one terminal window; TERM/TERM_PROGRAM guard against a later, +//! DIFFERENT emulator reusing a recycled tty number and inheriting a stale "silent" verdict it +//! never earned. The name must NOT come from an fd opened on `/dev/tty`: macOS's `ttyname_r` +//! reports that fd as the literal "/dev/tty", one constant key shared by every terminal window — +//! which let a single silent verdict (a dogfood run under `expect`) put every real kitty window +//! on the curated-dark fallback for the whole TTL (the 2026-07 auto-theme-goes-dark bug). //! //! ## Store //! A small human-readable JSON array of `{tty, term, term_program, timestamp}` objects under @@ -57,16 +61,22 @@ pub(crate) struct TerminalKey { term_program: String, } -/// Build this launch's [`TerminalKey`] from the controlling tty and environment. `None` when -/// there's no controlling tty to key against (no `/dev/tty`, not unix) — callers treat that the -/// same as a cache miss. +/// Build this launch's [`TerminalKey`] from the controlling tty and environment. `None` when no +/// standard fd is a tty to key against (stdio fully redirected, not unix) — callers treat that +/// the same as a cache miss, so an un-keyable launch just probes. +/// +/// The device name is resolved from the first of stdin/stdout/stderr that `isatty` reports — +/// deliberately NOT from an fd opened on `/dev/tty`, even though the probe itself converses over +/// `/dev/tty`: on macOS, `ttyname_r` on such an fd returns the literal "/dev/tty", collapsing +/// every terminal window onto one shared cache key (see the module doc's "Key" section for the +/// poisoning this caused). #[cfg(unix)] pub(crate) fn terminal_key() -> Option { use std::ffi::CStr; - use std::os::unix::io::AsRawFd; - let tty = std::fs::File::options().read(true).open("/dev/tty").ok()?; - let fd = tty.as_raw_fd(); + let fd = [libc::STDIN_FILENO, libc::STDOUT_FILENO, libc::STDERR_FILENO] + .into_iter() + .find(|&fd| unsafe { libc::isatty(fd) } == 1)?; let mut buf = [0 as std::os::raw::c_char; 256]; if unsafe { libc::ttyname_r(fd, buf.as_mut_ptr(), buf.len()) } != 0 { return None; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index bb24d2cd..04b2c264 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -1,4 +1,4 @@ -//! Frame rendering: header, side-by-side diff body, footer. +//! Frame rendering: per-pane headers, side-by-side diff body, footer. //! //! Ported from the `review-tui-spike` prototype's `ui.rs`, adapted to render [`App`]'s //! gap-collapsed [`crate::align::DisplayRow`]s instead of a flat aligned-row list, and extended @@ -15,7 +15,8 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::align::{CellKind, DisplayRow, InlineRow, Row}; use crate::app::{ - App, EffectiveZoom, FileView, Layout as AppLayout, Notice, Region, Role, Severity, Summary, + App, DiffTextMode, EffectiveZoom, FileView, Layout as AppLayout, Notice, Region, Role, + Severity, Summary, }; use crate::attribute::Attribution; use crate::config::View; @@ -60,9 +61,9 @@ const NERD_DIFF_ADDED: char = '\u{f457}'; // nf-oct-diff-added 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 and the -/// summary panel (and, upstack, the winbar) deliberately draw the SAME markers, so the selection -/// lives in one place instead of a hand-synced `match` per call site. +/// 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 +/// 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 { IconMode::Nerd => NERD_CURRENT_MARKER, @@ -106,6 +107,36 @@ fn diffstat_prefixes(icons: IconMode) -> (String, String) { } } +/// The pane headers' bold ` +A -D` diffstat span run (leading two-space spacer included) — +/// single-sourced for [`render_outline_header`] (changeset total) and [`file_segment_spans`] +/// (per-file), so the styling (spacing, boldness, glyph prefixes) can't drift between the two. +/// The summary panel's totals line deliberately keeps its own non-bold variant +/// ([`push_summary_body`]). +fn diffstat_spans( + adds: usize, + dels: usize, + theme: &Palette, + icons: IconMode, +) -> Vec> { + let (added_prefix, removed_prefix) = diffstat_prefixes(icons); + vec![ + TSpan::styled(" ".to_string(), Style::default().fg(theme.foreground)), + TSpan::styled( + format!("{added_prefix}{adds}"), + Style::default() + .fg(theme.add_fg) + .add_modifier(Modifier::BOLD), + ), + TSpan::styled(" ".to_string(), Style::default().fg(theme.foreground)), + TSpan::styled( + format!("{removed_prefix}{dels}"), + Style::default() + .fg(theme.del_fg) + .add_modifier(Modifier::BOLD), + ), + ] +} + /// 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, @@ -201,9 +232,29 @@ fn apply_row_tint(mut line: Line<'static>, width: u16, tint: Color) -> Line<'sta line } -/// Wash the cursor row with the theme's cursor tint. -fn apply_cursor_row(line: Line<'static>, width: u16, theme: &Palette) -> Line<'static> { - apply_row_tint(line, width, theme.cursor_bg) +/// The cursor row's tint — full [`Palette::cursor_bg`] when `focused` is true (this pane holds +/// focus), or the dimmer [`Palette::cursor_unfocused_bg`] otherwise. Shared by [`apply_cursor_row`] +/// and `render_pane_sbs`'s divider-cell re-tint so the row wash and the divider it crosses never +/// drift apart. +fn cursor_tint(theme: &Palette, focused: bool) -> Color { + if focused { + theme.cursor_bg + } else { + theme.cursor_unfocused_bg + } +} + +/// 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, +/// matching the outline's pre-existing focused/unfocused split). +fn apply_cursor_row( + line: Line<'static>, + width: u16, + theme: &Palette, + focused: bool, +) -> Line<'static> { + apply_row_tint(line, width, cursor_tint(theme, focused)) } /// Wash a selected (line-selection) row with the theme's selection tint. @@ -235,22 +286,32 @@ fn apply_right_edge_marker( } } -/// One resolved (bg, fg) pair for a byte range of a line. +/// One resolved (bg, fg, italic) triple for a byte range of a line. struct Segment { start: usize, end: usize, bg: Option, fg: Color, + /// Whether the covering syntax capture renders in italics (`theme::syntax_italic` — comments). + italic: bool, } -/// Merge background-role spans and syntax fg spans into a flat list of non-overlapping -/// segments covering `[0, len)`. A syntax span carries only its capture index; its color is -/// resolved HERE against `theme` (ADR-035's render-time resolution) — a segment with no covering -/// syntax span falls back to [`Palette::foreground`]. +/// Merge background-role spans, syntax fg spans, and `workon.review.diff.text` tint-foreground +/// override spans into a flat list of non-overlapping segments covering `[0, len)`. A syntax span +/// carries only its capture index; its color is resolved HERE against `theme` (ADR-035's +/// 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 +/// 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 +/// 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( len: usize, bg_spans: &[(usize, usize, Color)], fg_spans: Option<&Vec>, + fg_override_spans: &[(usize, usize, Color)], theme: &Palette, ) -> Vec { let mut boundaries: Vec = vec![0, len]; @@ -264,6 +325,10 @@ fn compose_segments( boundaries.push(span.end.min(len)); } } + for (s, e, _) in fg_override_spans { + boundaries.push((*s).min(len)); + boundaries.push((*e).min(len)); + } boundaries.sort_unstable(); boundaries.dedup(); @@ -274,20 +339,40 @@ fn compose_segments( continue; } let mid = start; - // Later-pushed bg spans are more specific (word-level strong emphasis is pushed after - // the whole-line subtle span in `content_spans`) and must win, so the lookup scans in + // Later-pushed bg spans are more specific (the word-level edit emphasis is pushed after + // the whole-line emphasis in `content_spans`) and must win, so the lookup scans in // REVERSE push order. The spike's forward `find` silently dropped word-level emphasis: - // the whole-line subtle span contains every offset, so it always matched first. + // the whole-line span contains every offset, so it always matched first. let bg = bg_spans .iter() .rev() .find(|(s, e, _)| mid >= *s && mid < *e) .map(|(_, _, c)| *c); - let fg = fg_spans - .and_then(|fgs| fgs.iter().find(|s| mid >= s.start && mid < s.end)) - .map(|s| theme.syntax(s.capture)) - .unwrap_or(theme.foreground); - segments.push(Segment { start, end, bg, fg }); + // 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 + // 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)); + let italic = syntax_hit + .map(|s| crate::theme::syntax_italic(s.capture)) + .unwrap_or(false); + let fg = fg_override_spans + .iter() + .rev() + .find(|(s, e, _)| mid >= *s && mid < *e) + .map(|(_, _, c)| *c) + .unwrap_or_else(|| { + syntax_hit + .map(|s| theme.syntax(s.capture)) + .unwrap_or(theme.foreground) + }); + segments.push(Segment { + start, + end, + bg, + fg, + italic, + }); } segments } @@ -296,7 +381,7 @@ fn gutter_width(max_lineno: usize) -> usize { max_lineno.to_string().len().max(3) } -/// How a rendered pane resolves a changed cell's (subtle, strong) background pair — one per +/// 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. #[derive(Clone, Copy)] @@ -342,39 +427,68 @@ fn attribution_mode(role: Role, attribution: &Option) -> Attributio } } -/// The (subtle, strong) background pair for a Del cell at `old_lnum`, given `mode`, resolved from -/// `theme`'s bright vs. staged Del tints. -fn del_bg_pair(mode: AttributionMode, old_lnum: u32, theme: &Palette) -> (Color, Color) { - let bright = (theme.del_subtle, theme.del_strong); - let staged = (theme.del_staged_subtle, theme.del_staged_strong); +/// 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 => bright, - AttributionMode::StagedUniform => staged, - AttributionMode::Attributed(attribution) => { - if attribution.del_is_staged(old_lnum) { - staged - } else { - bright - } - } + AttributionMode::Plain => false, + AttributionMode::StagedUniform => true, + AttributionMode::Attributed(attribution) => attribution.del_is_staged(old_lnum), } } -/// The (subtle, strong) background pair for an Add cell at `new_lnum`, given `mode`, resolved from -/// `theme`'s bright vs. staged Add tints. -fn add_bg_pair(mode: AttributionMode, new_lnum: u32, theme: &Palette) -> (Color, Color) { - let bright = (theme.add_subtle, theme.add_strong); - let staged = (theme.add_staged_subtle, theme.add_staged_strong); +/// 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 => bright, - AttributionMode::StagedUniform => staged, - AttributionMode::Attributed(attribution) => { - if attribution.add_is_unstaged(new_lnum) { - bright - } else { - staged - } - } + AttributionMode::Plain => false, + AttributionMode::StagedUniform => true, + AttributionMode::Attributed(attribution) => !attribution.add_is_unstaged(new_lnum), + } +} + +/// 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 + } else { + unstaged + } +} + +/// 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 + } else { + unstaged + } +} + +/// 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) { + 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) { + theme.add_staged_fg + } else { + theme.add_fg } } @@ -484,14 +598,34 @@ fn pan_spans(spans: Vec>, cols: usize, theme: &Palette) -> Vec>, cols: usize, theme: &Palette) -> Vec>, - emphasis: Option<(Color, Color)>, + emphasis: Option, word_spans: &[WordSpan], is_word_pair: bool, theme: &Palette, hscroll: usize, + text_mode: DiffTextMode, ) -> Vec> { let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); - if let Some((subtle_bg, strong_bg)) = emphasis { + let mut fg_override_spans: Vec<(usize, usize, Color)> = Vec::new(); + if let Some(LineEmphasis { + line_bg, + edit_bg, + tint_fg, + }) = emphasis + { if is_word_pair { - bg_spans.push((0, text.len(), subtle_bg)); + bg_spans.push((0, text.len(), line_bg)); for s in word_spans { - bg_spans.push((s.start, s.end, strong_bg)); + bg_spans.push((s.start, s.end, edit_bg)); } } else { - // Unpaired excess line: whole-line strong emphasis. - bg_spans.push((0, text.len(), strong_bg)); + // Unpaired excess line: no word-diff spans, so the whole line takes the edit wash + // full width — the line "is" the edit here (ADR-035's CS11 section: "edit" is the + // domain term precisely because this branch would falsify "word"). + bg_spans.push((0, text.len(), edit_bg)); + } + + match text_mode { + DiffTextMode::Syntax => {} + DiffTextMode::Tint => { + fg_override_spans.push((0, text.len(), tint_fg)); + } + DiffTextMode::Edit => { + // Mirrors the edit-background branch above exactly (same condition, same + // ranges) — the invariant this mode exists to hold. + if is_word_pair { + for s in word_spans { + fg_override_spans.push((s.start, s.end, tint_fg)); + } + } else { + fg_override_spans.push((0, text.len(), tint_fg)); + } + } } } - let segments = compose_segments(text.len(), &bg_spans, hl, theme); + let segments = compose_segments(text.len(), &bg_spans, hl, &fg_override_spans, theme); let mut spans = Vec::with_capacity(segments.len().max(1)); if segments.is_empty() && !text.is_empty() { spans.push(TSpan::styled( @@ -532,6 +693,9 @@ fn content_spans( if let Some(bg) = seg.bg { style = style.bg(bg); } + if seg.italic { + style = style.add_modifier(Modifier::ITALIC); + } spans.push(TSpan::styled(text[seg.start..seg.end].to_string(), style)); } pan_spans(spans, hscroll, theme) @@ -551,11 +715,12 @@ fn build_pane_line( content_w: usize, theme: &Palette, hscroll: usize, + text_mode: DiffTextMode, ) -> Line<'static> { match row { Row::Filler => { let pattern: String = "╱".repeat(content_w + gutter_w + 1); - Line::from(TSpan::styled(pattern, Style::default().fg(theme.dim))) + Line::from(TSpan::styled(pattern, Style::default().fg(theme.filler_fg))) } Row::Line(n) => { let text = match side { @@ -572,8 +737,22 @@ fn build_pane_line( let mut spans = vec![TSpan::styled(gutter, Style::default().fg(theme.gutter))]; let emphasis = match kind { - CellKind::Del => Some(del_bg_pair(mode, n as u32, theme)), - CellKind::Add => Some(add_bg_pair(mode, n as u32, theme)), + CellKind::Del => { + let (line_bg, edit_bg) = del_bg_pair(mode, n as u32, theme); + Some(LineEmphasis { + line_bg, + edit_bg, + tint_fg: del_tint_fg(mode, n as u32, theme), + }) + } + CellKind::Add => { + let (line_bg, edit_bg) = add_bg_pair(mode, n as u32, theme); + Some(LineEmphasis { + line_bg, + edit_bg, + tint_fg: add_tint_fg(mode, n as u32, theme), + }) + } CellKind::Context | CellKind::Filler => None, }; spans.extend(content_spans( @@ -584,15 +763,17 @@ fn build_pane_line( is_word_pair, theme, hscroll, + text_mode, )); Line::from(spans) } } } -/// Render one frame: header, SBS body, 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`]/ +/// Render one frame: SBS body (each pane painting its own 1-row header — CS1, `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`]/ /// [`crate::keymap::help_sections`]), never a hardcoded key string. `theme` is the resolved /// on-tint palette — see [`crate::theme`]; the diff body, syntax foreground, and cursor/selection /// washes all resolve their colors against it at paint time, as do the canvas background and the @@ -617,20 +798,19 @@ 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 + // 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 + // of the top-level layout reappears as the per-pane header carve-out inside `body_area`. let vlayout = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(1), - ]) + .constraints([Constraint::Min(1), Constraint::Length(1)]) .split(area); - let header_area = vlayout[0]; - let body_area = vlayout[1]; - let footer_area = vlayout[2]; + let body_area = vlayout[0]; + let footer_area = vlayout[1]; - render_header(frame, app, header_area, theme); render_footer(frame, app, footer_area, keymap, theme); if app.outline_open() { @@ -646,6 +826,9 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette let div_area = hlayout[1]; let diff_area = hlayout[2]; 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). for y in div_area.y..div_area.y + div_area.height { frame .buffer_mut() @@ -731,11 +914,98 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect frame.render_widget(Paragraph::new(lines).block(block), popup_area); } -/// Render the outline side pane's rows into `area`: [`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 +/// The style for a pane header/caption LABEL word (CS1, `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 +/// `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 +/// receive `focused == true` (the exactly-one-lit-label invariant — see the module's +/// `focused-pane-header` handoff); since `header-chrome-follows-focus` that one header may style +/// several spans (counter + label + changeset-prefix text) through this function with the same +/// flag, so the invariant counts lit headers, not call sites. +fn pane_header_label_style(theme: &Palette, focused: bool) -> Style { + if focused { + Style::default() + .fg(theme.pane_header_focused_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.dim) + } +} + +/// The outline pane's own top row (CS1, `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 +/// 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 +/// 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 +/// 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 +/// changeset could soften this (e.g. suppress the header in Flat mode) if it proves confusing in +/// practice. +fn render_outline_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palette, focused: bool) { + let cs = app.current_changeset(); + let i = app.current_cs() + 1; + let n = app.changeset_count(); + let title = crate::app::display_label(cs); + let icons = app.icon_mode(); + + let mut spans = vec![ + TSpan::styled( + format!("[{i}/{n}] "), + pane_header_label_style(theme, focused), + ), + TSpan::styled(title, pane_header_label_style(theme, focused)), + ]; + if cs.needs_restack { + spans.push(TSpan::styled( + format!(" {} needs restack", warn_marker(icons)), + Style::default() + .fg(theme.warn_fg) + .add_modifier(Modifier::BOLD), + )); + } + // A pending/failed changeset's `files()` is always empty (ADR-037) — skip the diffstat + // segment entirely rather than show a misleading "+0 -0" (same gate `render_winbar` used). + if !app.files().is_empty() { + let (adds, dels) = app + .files() + .iter() + .map(crate::summary::file_diffstat) + .fold((0, 0), |(a, d), (fa, fd)| (a + fa, d + fd)); + spans.extend(diffstat_spans(adds, dels, theme, icons)); + } + let line = Line::from(spans); + frame + .buffer_mut() + .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_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 /// [`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 @@ -745,13 +1015,21 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// 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::outline_cursor_unfocused_bg`] while it's merely open (so the remembered position stays +/// [`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`] /// 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. 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 + // 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()); + Rect::new(area.x, area.y + 1, area.width, area.height - 1) + } else { + area + }; app.outline_height = area.height as usize; app.hit_regions.outline = Some(region_from(area)); let (items, hidden_counts) = app.outline_items_with_hidden_counts(); @@ -787,10 +1065,8 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) let is_cursor = item_idx == cursor; let line = build_outline_line(item, theme, icons, hidden); let line = Line::from(pan_spans(line.spans, hscroll, theme)); - let line = if is_cursor && focused { - apply_cursor_row(line, area.width, theme) - } else if is_cursor { - apply_row_tint(line, area.width, theme.outline_cursor_unfocused_bg) + let line = if is_cursor { + apply_cursor_row(line, area.width, theme, focused) } else { line }; @@ -829,15 +1105,15 @@ fn tree_prefix(guides: &[bool]) -> String { /// a ragged single-letter row. const STATUS_PLACEHOLDER: char = '\u{b7}'; -/// A committed changeset's single-letter status color (CS3): A green (`add_strong`), D red -/// (`del_strong`), 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 +/// A committed changeset's single-letter status color (CS3, CS11): 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 /// committed changeset can't carry; both fold to `dim` only so this match stays exhaustive). fn committed_letter_color(change: FileStatus, theme: &Palette) -> Color { match change { - FileStatus::Added => theme.add_strong, - FileStatus::Deleted => theme.del_strong, + FileStatus::Added => theme.add_fg, + FileStatus::Deleted => theme.del_fg, FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied => theme.modified_fg, FileStatus::Untracked | FileStatus::Unmerged => theme.dim, } @@ -857,8 +1133,10 @@ fn committed_letter_color(change: FileStatus, theme: &Palette) -> Color { /// - `Unstaged`/`Staged`/`Partial` render the git-porcelain X/Y matrix: `letter` (from the SAME /// 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 `add_strong` green, Y (worktree) is `del_strong` red, matching -/// git's own status convention. +/// 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.) fn outline_status_spans( status: crate::outline::StagedStatus, change: FileStatus, @@ -889,12 +1167,8 @@ fn outline_status_spans( let unstaged = matches!(status, StagedStatus::Unstaged | StagedStatus::Partial); let x_char = if staged { letter } else { STATUS_PLACEHOLDER }; let y_char = if unstaged { letter } else { STATUS_PLACEHOLDER }; - let x_color = if staged { theme.add_strong } else { theme.dim }; - let y_color = if unstaged { - theme.del_strong - } else { - theme.dim - }; + let x_color = if staged { theme.add_fg } else { theme.dim }; + let y_color = if unstaged { theme.del_fg } else { theme.dim }; vec![ TSpan::styled(x_char.to_string(), Style::default().fg(x_color)), TSpan::styled(y_char.to_string(), Style::default().fg(y_color)), @@ -1015,9 +1289,18 @@ fn build_outline_line( path, crate::theme::is_light_background(theme.background), ); + // Nerd-font icon colors are palette-external (hardcoded per-filetype `Rgb` from + // `icons::icon_for_path`, not a `Palette` field), so a colorless (NO_COLOR) theme + // must collapse them to `foreground` itself — see `Palette::colorless`'s doc + // comment. + let icon_fg = if theme.colorless { + theme.foreground + } else { + color.unwrap_or(theme.foreground) + }; spans.push(TSpan::styled( format!("{icon} "), - Style::default().fg(color.unwrap_or(theme.foreground)), + Style::default().fg(icon_fg), )); } // Flat/Stack rows (empty `guides`) split `path` at render time into `basename dim/ @@ -1053,9 +1336,9 @@ fn build_outline_line( } } -/// The current file's label for the top status row: its path, or a rename's `old @ base -> -/// path` form — shared by the lone-changeset header and the multi-changeset winbar (they differ -/// only in what wraps this). +/// The current file's label for the diff pane header: its path, or a rename's `old @ base -> +/// path` form — shared by every diff-header state ([`file_segment_spans`]) and the summary +/// panel's own current-changeset-independent uses. fn current_file_label(app: &App) -> String { match app.files().get(app.current) { Some(f) if f.status == FileStatus::Renamed || f.status == FileStatus::Copied => { @@ -1071,32 +1354,8 @@ fn current_file_label(app: &App) -> String { } } -/// The top status row: `[fidx/nfiles] path` for a lone changeset (the M4 look, unchanged), or the -/// changeset-aware winbar (locked decision #8) once the stack has more than one changeset — the -/// winbar's own `[i/n]` is the CHANGESET counter, so showing both here would render two different -/// counters under the same bracket notation. Never both at once. -fn render_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { - if app.changeset_count() > 1 { - render_winbar(frame, app, area, theme); - return; - } - let idx = app.current + 1; - let n = app.files().len(); - let text = format!("[{idx}/{n}] {}", current_file_label(app)); - let mut spans = vec![TSpan::styled( - text, - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), - )]; - if let Some(span) = hscroll_indicator_span(app, theme) { - spans.push(span); - } - frame.render_widget(Paragraph::new(Line::from(spans)), area); -} - -/// While [`App::hscroll`] is panned, a small dim `»42` (the column offset) appended to the header/ -/// winbar (locked decision #8) — `None` at column `0`, matching the diffstat span's own +/// 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 /// present-or-absent pattern above/below. fn hscroll_indicator_span(app: &App, theme: &Palette) -> Option> { if app.hscroll == 0 { @@ -1108,97 +1367,155 @@ fn hscroll_indicator_span(app: &App, theme: &Palette) -> Option> )) } -/// The multi-changeset winbar (locked decisions #8 + #9): `[i/n] -/// (fidx/nfiles)`, where `i/n` is the changeset's position -/// in the stack and `fidx/nfiles` the active file's position within it. Only reached when -/// [`App::changeset_count`] > 1 (see [`render_header`]) — a lone uncommitted changeset never -/// shows this, keeping the M4 full-width look. -/// -/// CS4 polish: a tight `+A -D` diffstat for the ACTIVE changeset (there wasn't one before), -/// tinted with the same [`Palette::add_strong`]/[`Palette::del_strong`] the summary panel's own -/// totals line uses; in [`IconMode::Nerd`] mode the restack marker and diffstat prefixes swap -/// to their nerd glyphs (same consts `build_outline_line`/`push_summary_body` use), and the -/// active file's path gets its devicons file icon. -fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { +/// CS1 (`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`) +/// 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). +fn changeset_prefix_spans( + app: &App, + theme: &Palette, + icons: IconMode, + focused: bool, +) -> Vec> { let cs = app.current_changeset(); let i = app.current_cs() + 1; let n = app.changeset_count(); let title = crate::app::display_label(cs); - let icons = app.icon_mode(); let mut spans = vec![TSpan::styled( format!("[{i}/{n}] {title}"), - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), + pane_header_label_style(theme, focused), )]; // A boolean-driven glyph + color (locked decision #9), 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( - format!(" {} needs restack", warn_marker(icons)), + format!(" {}", warn_marker(icons)), Style::default() .fg(theme.warn_fg) .add_modifier(Modifier::BOLD), )); } - // A pending/failed changeset's `files()` is always empty (ADR-037) — skip the diffstat - // segment entirely rather than show a misleading "+0 -0". - if !app.files().is_empty() { - let (adds, dels) = app - .files() - .iter() - .map(crate::summary::file_diffstat) - .fold((0, 0), |(a, d), (fa, fd)| (a + fa, d + fd)); - let (added_prefix, removed_prefix) = diffstat_prefixes(icons); - spans.push(TSpan::raw(" ")); - spans.push(TSpan::styled( - format!("{added_prefix}{adds}"), - Style::default() - .fg(theme.add_strong) - .add_modifier(Modifier::BOLD), - )); - spans.push(TSpan::raw(" ")); - spans.push(TSpan::styled( - format!("{removed_prefix}{dels}"), - Style::default() - .fg(theme.del_strong) - .add_modifier(Modifier::BOLD), - )); - } - let fidx = app.current + 1; - let nfiles = app.files().len(); - spans.push(TSpan::styled( - " — ".to_string(), - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), - )); + spans +} + +/// The diff pane header's shared "current file" segment (CS1, `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 +/// `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 +/// diffstat, never a per-file one — [`crate::summary::file_diffstat`] gives the same recorded +/// 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 +/// 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). +fn file_segment_spans( + app: &App, + theme: &Palette, + icons: IconMode, + focused: bool, +) -> Vec> { + let idx = app.current + 1; + let n = app.files().len(); + let mut spans = vec![TSpan::styled( + format!("[{idx}/{n}] "), + 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( &f.path, crate::theme::is_light_background(theme.background), ); + // Same palette-external collapse as the outline's icon paint site above — see + // `Palette::colorless`'s doc comment. + let icon_fg = if theme.colorless { + theme.foreground + } else { + color.unwrap_or(theme.foreground) + }; spans.push(TSpan::styled( format!("{icon} "), - Style::default() - .fg(color.unwrap_or(theme.foreground)) - .add_modifier(Modifier::BOLD), + Style::default().fg(icon_fg).add_modifier(Modifier::BOLD), )); } } spans.push(TSpan::styled( - format!("{} ({fidx}/{nfiles})", current_file_label(app)), - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), + current_file_label(app), + pane_header_label_style(theme, focused), )); + if let Some(f) = app.files().get(app.current) { + let (adds, dels) = crate::summary::file_diffstat(f); + spans.extend(diffstat_spans(adds, dels, theme, icons)); + } if let Some(span) = hscroll_indicator_span(app, theme) { spans.push(span); } + spans +} + +/// The diff pane's own top-row header (CS1, `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: +/// +/// - Outline open: [`file_segment_spans`] alone (the outline pane's own header already carries +/// the changeset-position context, so this stays file-focused). +/// - 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 +/// 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]`. +/// "Blank" still carries an explicit `theme.foreground`-styled space (not a zero-span [`Line`]) +/// — an empty span list leaves the row's cells at whatever style predates this frame's paint +/// (`Style::default()`'s `Reset` fg, even under a painted canvas, since [`Buffer::set_line`] +/// writes nothing for zero-width content) rather than the theme's own baseline (regression: +/// `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 +/// dim even while the diff has focus in that case. +fn diff_header_line(app: &App, theme: &Palette, icons: IconMode, focused: bool) -> 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() { + return if show_prefix { + Line::from(changeset_prefix_spans(app, theme, icons, focused)) + } else { + Line::from(TSpan::styled( + " ".to_string(), + Style::default().fg(theme.foreground), + )) + }; + } - frame.render_widget(Paragraph::new(Line::from(spans)), area); + let mut spans = Vec::new(); + if show_prefix { + spans.extend(changeset_prefix_spans(app, theme, icons, focused)); + spans.push(TSpan::styled( + " — ".to_string(), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), + )); + } + spans.extend(file_segment_spans(app, theme, icons, focused)); + Line::from(spans) } /// Footer priority: a pending discard confirm's prompt (warn-toned) wins over a transient notice, @@ -1245,6 +1562,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, them /// Write a gap row's `··· N unchanged lines (Enter to expand) ···` marker across the FULL body /// width (both panes and the divider column) — unlike a per-pane content row, a gap hides the /// same span on both sides, so it isn't "about" one side or the other. +#[allow(clippy::too_many_arguments)] fn render_gap_row( buf: &mut Buffer, area: Rect, @@ -1253,12 +1571,13 @@ fn render_gap_row( is_cursor: bool, is_selected: bool, theme: &Palette, + focused: bool, ) { let msg = format!("··· {skipped} unchanged lines (Enter to expand) ···"); let line = Line::from(TSpan::styled(msg, Style::default().fg(theme.dim))); // Cursor wins over selection on the same row. let line = if is_cursor { - apply_cursor_row(line, area.width, theme) + apply_cursor_row(line, area.width, theme, focused) } else if is_selected { apply_selection_row(line, area.width, theme) } else { @@ -1307,23 +1626,17 @@ fn render_loading_placeholder( } /// Push a `"path +N -M"` file row's spans onto `lines`: the path in the theme foreground, the -/// add/del counts tinted with the theme's own diff-add/diff-del colors (the strong variants — the -/// same tint a hunk's `+`/`-` gutter itself uses, see [`Palette::add_strong`]/ -/// [`Palette::del_strong`]) so the panel's diffstat reads consistently with the diff body it's +/// add/del counts tinted with the theme's own diff-add/diff-del foregrounds ([`Palette::add_fg`]/ +/// [`Palette::del_fg`] — the same tint the outline's X/Y status letters use, see +/// [`committed_letter_color`]) so the panel's diffstat reads consistently with the diff body it's /// standing in for. fn push_summary_file_row(lines: &mut Vec>, row: &SummaryFileRow, theme: &Palette) { lines.push(Line::from(vec![ TSpan::styled(row.path.clone(), Style::default().fg(theme.foreground)), TSpan::raw(" "), - TSpan::styled( - format!("+{}", row.adds), - Style::default().fg(theme.add_strong), - ), + TSpan::styled(format!("+{}", row.adds), Style::default().fg(theme.add_fg)), TSpan::raw(" "), - TSpan::styled( - format!("-{}", row.dels), - Style::default().fg(theme.del_strong), - ), + TSpan::styled(format!("-{}", row.dels), Style::default().fg(theme.del_fg)), ])); } @@ -1379,38 +1692,39 @@ fn push_summary_body( TSpan::raw(" "), TSpan::styled( format!("{added_prefix}{total_adds}"), - Style::default().fg(theme.add_strong), + Style::default().fg(theme.add_fg), ), TSpan::raw(" "), TSpan::styled( format!("{removed_prefix}{total_dels}"), - Style::default().fg(theme.del_strong), + Style::default().fg(theme.del_fg), ), ])); } -/// Build a [`ChangesetSummary`]'s lines: title line (the same current/needs-restack markers +/// 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), a -/// loading/failed line OR the per-file list + totals line. +/// 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`) +/// 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( summary: &ChangesetSummary, height: usize, theme: &Palette, icons: IconMode, -) -> Vec> { - let mut lines = Vec::new(); - - lines.push(Line::from(changeset_title_spans( +) -> (Vec>, Vec>) { + let title = changeset_title_spans( &summary.label, summary.current, summary.needs_restack, theme, icons, None, - ))); + ); + let mut lines = Vec::new(); if summary.failed { let msg = summary .failure_message @@ -1420,14 +1734,14 @@ fn changeset_summary_lines( format!("{} {msg}", error_marker(icons)), Style::default().fg(theme.error_fg), ))); - return lines; + return (title, lines); } if summary.loading { lines.push(Line::from(TSpan::styled( format!("Loading{}", loading_marker(icons)), Style::default().fg(theme.dim), ))); - return lines; + return (title, lines); } push_summary_body( @@ -1439,29 +1753,32 @@ fn changeset_summary_lines( theme, icons, ); - lines + (title, lines) } -/// Build a [`DirSummary`]'s lines: a bold path title, a blank line, the per-file list, and the -/// totals 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`). +/// 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 +/// `(title, body)` tuple now instead of one combined line list. fn dir_summary_lines( summary: &DirSummary, height: usize, theme: &Palette, icons: IconMode, -) -> Vec> { +) -> (Vec>, Vec>) { let dir_icon = match icons { IconMode::Nerd => format!("{} ", crate::icons::DIR_ICON), IconMode::None => String::new(), }; - let mut lines = vec![Line::from(TSpan::styled( + let title = vec![TSpan::styled( format!("{dir_icon}{}/", summary.path), Style::default() .fg(theme.foreground) .add_modifier(Modifier::BOLD), - ))]; + )]; + let mut lines = Vec::new(); push_summary_body( &mut lines, &summary.files, @@ -1471,39 +1788,92 @@ fn dir_summary_lines( theme, icons, ); - lines + (title, lines) } /// CS4's 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`]) — a title line, a blank -/// line, per-file `"path +N -M"` rows (truncated to the pane height), and a totals line. A -/// loading/failed Header shows its own inline state instead of a file list (see -/// [`changeset_summary_lines`]). +/// 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, +/// `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`]). fn render_summary( frame: &mut Frame, summary: &Summary, area: Rect, theme: &Palette, icons: IconMode, -) { +) -> Line<'static> { let height = area.height as usize; - let lines = match summary { + let (title, lines) = match summary { Summary::Changeset(cs) => changeset_summary_lines(cs, height, theme, icons), Summary::Dir(dir) => dir_summary_lines(dir, height, theme, icons), }; frame.render_widget(Paragraph::new(lines), area); + Line::from(title) } 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 + // (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 + // (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 + // `render`'s top-level layout reappears as this per-pane carve-out. + let (header_area, area) = if area.height >= 2 { + ( + Some(Rect::new(area.x, area.y, area.width, 1)), + Rect::new(area.x, area.y + 1, area.width, area.height - 1), + ) + } else { + (None, area) + }; + // CS4: 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 + // spans paint the header row below, its body-only lines paint `render_summary`'s content. let summary = app.summary_for(target); - render_summary(frame, &summary, area, theme, app.icon_mode()); + let title = render_summary(frame, &summary, area, theme, icons); + if let Some(header_area) = header_area { + frame + .buffer_mut() + .set_line(header_area.x, header_area.y, &title, header_area.width); + } return; } + + if let Some(header_area) = header_area { + // CS1 (`focused-pane-header`, locked decision #5): 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 + // concern here either. + // + // Exception: `render_body_split`'s own short-area fallback (`area.height < 4`) renders + // only the focused pane and returns before either caption is drawn — no split caption + // survives to be the frame's lit label. `area` here is the exact same rect that fallback + // 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 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); + frame + .buffer_mut() + .set_line(header_area.x, header_area.y, &line, header_area.width); + } // ADR-037: the active changeset's diff hasn't been acquired (or failed to acquire) yet — // both cases have an empty `files()` list, so they must be checked BEFORE the "(no changes)" // fallback below, which would otherwise misreport a Pending/Failed changeset as an @@ -1560,12 +1930,16 @@ 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 + // 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(); match app.layout { AppLayout::Sbs => render_pane_sbs( - frame, app, area, idx, role, scroll, cursor, selection, theme, + frame, app, area, idx, role, scroll, cursor, selection, theme, focused, ), AppLayout::Inline => render_pane_inline( - frame, app, area, idx, role, scroll, cursor, selection, theme, + frame, app, area, idx, role, scroll, cursor, selection, theme, focused, ), } } @@ -1579,6 +1953,12 @@ 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 + // 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). + 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. if area.height < 4 { @@ -1586,12 +1966,15 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t app.pane_height = area.height as usize; let (scroll, cursor) = app.pane_render_state(role); let selection = app.selection_range(); + // `split_focus_role()`'s pane is only the frame's REAL focus while the outline doesn't + // hold it (same rule as the split's two-caption branch below). + let focused = !outline_focused; match app.layout { AppLayout::Sbs => render_pane_sbs( - frame, app, area, idx, role, scroll, cursor, selection, theme, + frame, app, area, idx, role, scroll, cursor, selection, theme, focused, ), AppLayout::Inline => render_pane_inline( - frame, app, area, idx, role, scroll, cursor, selection, theme, + frame, app, area, idx, role, scroll, cursor, selection, theme, focused, ), } return; @@ -1622,16 +2005,35 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t app.clamp_scroll(); app.clamp_alt_scroll(); - render_caption(frame.buffer_mut(), unstaged_caption, "UNSTAGED", theme); - render_caption(frame.buffer_mut(), staged_caption, "STAGED", theme); + // Each half's REAL focus (CS1, `unfocused-cursor-wash` — locked decisions #1/#5): 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 + // half now always carries — its remembered position, per `App::pane_render_state`'s updated + // doc comment). + let u_focused = !outline_focused && app.split_focus_role() == Role::Unstaged; + let s_focused = !outline_focused && app.split_focus_role() == Role::Staged; + + render_caption( + frame.buffer_mut(), + unstaged_caption, + "UNSTAGED", + theme, + u_focused, + ); + render_caption( + frame.buffer_mut(), + staged_caption, + "STAGED", + theme, + s_focused, + ); let (u_scroll, u_cursor) = app.pane_render_state(Role::Unstaged); let (s_scroll, s_cursor) = app.pane_render_state(Role::Staged); - // A selection lives in the focused pane only — the one whose `pane_render_state` yields a - // cursor. Show it there, `None` in the unfocused pane. let range = app.selection_range(); - let u_selection = u_cursor.and(range); - let s_selection = s_cursor.and(range); + let u_selection = if u_focused { range } else { None }; + let s_selection = if s_focused { range } else { None }; match app.layout { AppLayout::Sbs => { render_pane_sbs( @@ -1644,6 +2046,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t u_cursor, u_selection, theme, + u_focused, ); render_pane_sbs( frame, @@ -1655,6 +2058,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t s_cursor, s_selection, theme, + s_focused, ); } AppLayout::Inline => { @@ -1668,6 +2072,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t u_cursor, u_selection, theme, + u_focused, ); render_pane_inline( frame, @@ -1679,22 +2084,37 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t s_cursor, s_selection, theme, + s_focused, ); } } } -/// Write a split pane's role caption (`── LABEL ──`) across the pane width, styled like the dim -/// gap-row markers. -fn render_caption(buf: &mut Buffer, area: Rect, label: &str, theme: &Palette) { - let text = format!("── {label} ──"); - let line = Line::from(TSpan::styled(text, Style::default().fg(theme.dim))); +/// Write a split pane's role caption (`── LABEL ────…`) across the FULL pane width — the rule +/// 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 +/// 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); + let used = 3 + label.chars().count() + 1; // "── " + label + " " + let fill = (area.width as usize).saturating_sub(used); + let line = Line::from(vec![ + TSpan::styled("── ", rule_style), + TSpan::styled(label.to_string(), pane_header_label_style(theme, focused)), + TSpan::styled(format!(" {}", "─".repeat(fill)), rule_style), + ]); buf.set_line(area.x, area.y, &line, area.width); } /// Render one SBS pane of `role`'s view for file `idx` into `area`, scrolled to `scroll`. The -/// cursor-row highlight draws only when `cursor` is `Some` (the focused pane) and matches a visible -/// row — a split's unfocused pane passes `None`. +/// 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 +/// 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 +/// (`outline_focused`, `split_focus_role`), never guessed here from `cursor`/`selection` alone. #[allow(clippy::too_many_arguments)] fn render_pane_sbs( frame: &mut Frame, @@ -1706,6 +2126,7 @@ fn render_pane_sbs( cursor: Option, selection: Option<(usize, usize)>, theme: &Palette, + focused: bool, ) { let left_w = area.width.saturating_sub(1) / 2; let right_w = area.width.saturating_sub(1).saturating_sub(left_w); @@ -1723,6 +2144,8 @@ fn render_pane_sbs( // One offset shared by every content pane (locked decision #1) — 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. + let text_mode = app.diff_text; let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), old_area); @@ -1777,6 +2200,7 @@ fn render_pane_sbs( is_cursor, is_selected, theme, + focused, ); } DisplayRow::Row(row) => { @@ -1799,6 +2223,7 @@ fn render_pane_sbs( old_area.width as usize, theme, hscroll, + text_mode, ); let new_line = build_pane_line( view, @@ -1812,12 +2237,13 @@ fn render_pane_sbs( new_area.width as usize, theme, hscroll, + text_mode, ); // Cursor wins over selection on the same row (see [`Palette::selection_bg`]). let (old_line, new_line) = if is_cursor { ( - apply_cursor_row(old_line, old_area.width, theme), - apply_cursor_row(new_line, new_area.width, theme), + apply_cursor_row(old_line, old_area.width, theme, focused), + apply_cursor_row(new_line, new_area.width, theme, focused), ) } else if is_selected { ( @@ -1842,12 +2268,15 @@ fn render_pane_sbs( // The divider column was painted once for the whole pane height above, with the // default background; re-tint just this row's divider cell so the cursor wash // covers the full width (panes AND the `│` between them), like `render_gap_row`. + // Must carry whichever wash the row actually got — full when `focused`, dim + // otherwise — or the divider cell stays bright on a dimmed row. if is_cursor { + let tint = cursor_tint(theme, focused); frame.buffer_mut().set_string( div_area.x, y, "│", - Style::default().fg(theme.dim).bg(theme.cursor_bg), + Style::default().fg(theme.dim).bg(tint), ); } } @@ -1881,6 +2310,7 @@ fn build_inline_line( new_gutter_w: usize, theme: &Palette, hscroll: usize, + text_mode: DiffTextMode, ) -> Line<'static> { let (old_opt, new_opt, text, hl, kind) = match *row { InlineRow::Context { old, new } => ( @@ -1920,8 +2350,22 @@ fn build_inline_line( // `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). let emphasis = match kind { - CellKind::Del => old_opt.map(|n| del_bg_pair(mode, n as u32, theme)), - CellKind::Add => new_opt.map(|n| add_bg_pair(mode, n as u32, theme)), + CellKind::Del => old_opt.map(|n| { + let (line_bg, edit_bg) = del_bg_pair(mode, n as u32, theme); + LineEmphasis { + line_bg, + edit_bg, + tint_fg: del_tint_fg(mode, n as u32, theme), + } + }), + CellKind::Add => new_opt.map(|n| { + let (line_bg, edit_bg) = add_bg_pair(mode, n as u32, theme); + LineEmphasis { + line_bg, + edit_bg, + tint_fg: add_tint_fg(mode, n as u32, theme), + } + }), CellKind::Context | CellKind::Filler => None, }; spans.extend(content_spans( @@ -1932,6 +2376,7 @@ fn build_inline_line( is_word_pair, theme, hscroll, + text_mode, )); Line::from(spans) } @@ -1950,10 +2395,13 @@ fn render_pane_inline( cursor: Option, selection: Option<(usize, usize)>, theme: &Palette, + focused: bool, ) { // One offset shared by every content pane (locked decision #1) — 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. + let text_mode = app.diff_text; let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), area); @@ -1995,6 +2443,7 @@ fn render_pane_inline( is_cursor, is_selected, theme, + focused, ); } row => { @@ -2017,10 +2466,11 @@ fn render_pane_inline( new_gutter_w, theme, hscroll, + text_mode, ); // Cursor wins over selection on the same row (see [`Palette::selection_bg`]). let line = if is_cursor { - apply_cursor_row(line, area.width, theme) + apply_cursor_row(line, area.width, theme, focused) } else if is_selected { apply_selection_row(line, area.width, theme) } else { @@ -2039,20 +2489,25 @@ fn render_pane_inline( mod tests { use ratatui::backend::TestBackend; use ratatui::buffer::Buffer; - use ratatui::style::Style; + use ratatui::style::{Color, Modifier, Style}; use ratatui::text::Span as TSpan; use ratatui::Terminal; use git_workon_fixture::prelude::*; use unicode_width::UnicodeWidthChar; - use super::{hscroll_cut, pan_spans, render, STATUS_PLACEHOLDER}; + use super::{ + changeset_prefix_spans, compose_segments, content_spans, hscroll_cut, pan_spans, + pane_header_label_style, render, LineEmphasis, STATUS_PLACEHOLDER, + }; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; - use crate::app::App; + use crate::app::{App, DiffTextMode, EffectiveZoom, Role}; + use crate::highlight::FgSpan; use crate::keymap::Keymap; use crate::outline::OutlineItem; use crate::theme::Palette; + use crate::wordiff::Span as WordSpan; /// Render one frame against the default (unrebound) keymap and the dark theme — the vast /// majority of `render.rs` tests don't care about keybindings and only ever ran dark. Tests @@ -2068,32 +2523,278 @@ mod tests { terminal.backend().buffer().clone() } - /// Like [`render_once`] but with a caller-chosen theme — for the canvas-paint tests, which - /// need to compare `light` vs `dark` (not just always-dark). - fn render_once_themed(app: &mut App, width: u16, height: u16, theme: &Palette) -> Buffer { - let backend = TestBackend::new(width, height); - let mut terminal = Terminal::new(backend).unwrap(); - let keymap = Keymap::defaults(); - terminal.draw(|f| render(f, app, &keymap, theme)).unwrap(); - terminal.backend().buffer().clone() + #[test] + fn compose_segments_marks_comment_captures_italic() { + // The italics are structural (crate::theme::SYNTAX_ITALICS), resolved per capture at the + // same place the syntax color is — a comment segment carries italic, its neighbors don't. + let theme = Palette::dark(); + let comment = crate::highlight::capture_index("comment").unwrap(); + let keyword = crate::highlight::capture_index("keyword").unwrap(); + let fgs = vec![ + FgSpan { + start: 0, + end: 4, + capture: comment, + }, + FgSpan { + start: 4, + end: 8, + capture: keyword, + }, + ]; + let segments = compose_segments(8, &[], Some(&fgs), &[], &theme); + assert!(segments[0].italic, "comment segment renders italic"); + assert!(!segments[1].italic, "keyword segment stays upright"); } - fn cell_text(buf: &Buffer, x: u16, y: u16) -> &str { - buf.cell((x, y)).unwrap().symbol() - } + // ── CS11: `workon.review.diff.text` (`DiffTextMode`) foreground selection ────────── - fn buf_lines(buf: &Buffer) -> Vec { - (0..buf.area.height) - .map(|y| (0..buf.area.width).map(|x| cell_text(buf, x, y)).collect()) - .collect() + /// 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. + fn fgs_of(spans: &[TSpan<'static>]) -> Vec> { + spans.iter().map(|s| s.style.fg).collect() } #[test] - fn small_modified_file_shows_gap_hunk_and_word_diff() { - // 12 lines of context around a single changed word, with more than 2*CONTEXT_LINES of - // untouched lines both before and after so a gap collapses on both edges. - let old = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nold word here\nl10\nl11\nl12\nl13\nl14\n"; - let new = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nnew word here\nl10\nl11\nl12\nl13\nl14\n"; + fn content_spans_syntax_mode_ignores_tint_fg_entirely() { + // The changeset's primary gate (ADR-035 CS11): 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(); + let emphasis = |tint_fg| { + Some(LineEmphasis { + line_bg: theme.del_line_bg, + edit_bg: theme.del_edit_bg, + tint_fg, + }) + }; + let word_spans = [WordSpan { start: 0, end: 2 }]; + + for (is_word_pair, words) in [(true, word_spans.as_slice()), (false, &[])] { + let with_tint = content_spans( + "hello", + None, + emphasis(theme.del_fg), + words, + is_word_pair, + &theme, + 0, + DiffTextMode::Syntax, + ); + let without_tint = content_spans( + "hello", + None, + emphasis(theme.foreground), + words, + is_word_pair, + &theme, + 0, + DiffTextMode::Syntax, + ); + assert_eq!( + with_tint, without_tint, + "Syntax mode must ignore tint_fg (is_word_pair={is_word_pair})" + ); + // And it must actually resolve through syntax/theme.foreground, not the tint, so + // this isn't vacuously true from both sides being untinted the same wrong way. + assert!( + fgs_of(&with_tint) + .iter() + .all(|fg| *fg != Some(theme.del_fg)), + "Syntax mode must never paint the tint foreground" + ); + } + } + + #[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 + // 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. + let theme = Palette::dark(); + for mode in [DiffTextMode::Syntax, DiffTextMode::Tint, DiffTextMode::Edit] { + let spans = content_spans("hello", None, None, &[], false, &theme, 0, mode); + assert!( + fgs_of(&spans) + .iter() + .all(|fg| *fg == Some(theme.foreground)), + "context line must render plain (no tint fg) under {mode:?}" + ); + } + } + + #[test] + fn content_spans_tint_mode_paints_the_tint_fg_across_the_full_line() { + let theme = Palette::dark(); + let emphasis = Some(LineEmphasis { + line_bg: theme.add_line_bg, + edit_bg: theme.add_edit_bg, + tint_fg: theme.add_fg, + }); + let spans = content_spans( + "hello", + None, + emphasis, + &[WordSpan { start: 0, end: 2 }], + true, + &theme, + 0, + DiffTextMode::Tint, + ); + assert!( + fgs_of(&spans).iter().all(|fg| *fg == Some(theme.add_fg)), + "Tint mode must paint every segment of a changed line with the tint fg: {:?}", + fgs_of(&spans) + ); + } + + #[test] + fn content_spans_edit_mode_paints_only_the_word_span_on_a_paired_line() { + let theme = Palette::dark(); + let emphasis = Some(LineEmphasis { + line_bg: theme.add_line_bg, + edit_bg: theme.add_edit_bg, + tint_fg: theme.add_fg, + }); + // "hello" with the word span covering only "he" (bytes 0..2) — the rest of the line + // should keep its syntax/plain foreground, not the tint. + let spans = content_spans( + "hello", + None, + emphasis, + &[WordSpan { start: 0, end: 2 }], + true, + &theme, + 0, + DiffTextMode::Edit, + ); + let tinted: Vec<&TSpan> = spans + .iter() + .filter(|s| s.style.fg == Some(theme.add_fg)) + .collect(); + let plain: Vec<&TSpan> = spans + .iter() + .filter(|s| s.style.fg != Some(theme.add_fg)) + .collect(); + assert!(!tinted.is_empty(), "the word span itself must be tinted"); + assert!( + !plain.is_empty(), + "the rest of a paired line must NOT be tinted in Edit mode" + ); + assert_eq!( + tinted + .iter() + .map(|s| s.content.as_ref()) + .collect::(), + "he", + "only the word-diff range takes the tint fg" + ); + } + + #[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 + // 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. + let theme = Palette::dark(); + let emphasis = Some(LineEmphasis { + line_bg: theme.del_line_bg, + edit_bg: theme.del_edit_bg, + tint_fg: theme.del_fg, + }); + let spans = content_spans( + "hello", + None, + emphasis, + &[], // no word spans: this line has no pair to word-diff against + false, + &theme, + 0, + DiffTextMode::Edit, + ); + assert!( + fgs_of(&spans).iter().all(|fg| *fg == Some(theme.del_fg)), + "wherever the edit wash is painted (here: the whole unpaired line), the tint \ + foreground must be painted too: {:?}", + fgs_of(&spans) + ); + // And the background side of the same invariant, unchanged by this changeset — the edit + // wash really does cover the full width here, which is what makes the fg assertion above + // meaningful rather than accidental. + let segments = compose_segments(5, &[(0, 5, theme.del_edit_bg)], None, &[], &theme); + assert!(segments.iter().all(|s| s.bg == Some(theme.del_edit_bg))); + } + + /// Like [`render_once`] but with a caller-chosen theme — for the canvas-paint tests, which + /// need to compare `light` vs `dark` (not just always-dark). + fn render_once_themed(app: &mut App, width: u16, height: u16, theme: &Palette) -> Buffer { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + let keymap = Keymap::defaults(); + terminal.draw(|f| render(f, app, &keymap, theme)).unwrap(); + terminal.backend().buffer().clone() + } + + fn cell_text(buf: &Buffer, x: u16, y: u16) -> &str { + buf.cell((x, y)).unwrap().symbol() + } + + fn buf_lines(buf: &Buffer) -> Vec { + (0..buf.area.height) + .map(|y| (0..buf.area.width).map(|x| cell_text(buf, x, y)).collect()) + .collect() + } + + /// Find the row (by line index) whose caption reads `label` — e.g. "UNSTAGED" or "STAGED". + /// The `label != "STAGED" || !line.contains("UNSTAGED")` guard disambiguates the two: the + /// UNSTAGED caption row's tail can itself contain the substring "STAGED". + fn caption_row(content: &[String], label: &str) -> usize { + content + .iter() + .position(|line| { + line.contains(label) && (label != "STAGED" || !line.contains("UNSTAGED")) + }) + .unwrap_or_else(|| panic!("{label} caption present")) + } + + /// Find the first row in `start..end` whose text (columns `x0..buf.area.width`, so callers can + /// exclude an outline/gutter to the left) contains `text`. Used by the split-half cursor-wash + /// tests to locate each pane's cursor row bounded to that pane's own row range, disambiguating + /// text that appears once per pane. + fn find_row(buf: &Buffer, x0: u16, start: usize, end: usize, text: &str) -> u16 { + (start..end) + .find(|&y| { + (x0..buf.area.width) + .map(|x| cell_text(buf, x, y as u16)) + .collect::() + .contains(text) + }) + .unwrap_or_else(|| panic!("row containing {text:?} not found in {start}..{end}")) + as u16 + } + + /// Find `label`'s starting display COLUMN within `row` — a `chars()` window search (not + /// `String::find`'s byte offset), matching the convention several outline/summary-header tests + /// already use for a row that may carry multi-byte glyphs (`•`/`⚠`) ahead of the label; every + /// rendered cell here is exactly one column wide, so a `chars()` position IS the display + /// column, as long as `row` starts at buffer column 0 (true for every `buf_lines` row). + fn find_label_x(row: &str, label: &str) -> u16 { + let label_chars: Vec = label.chars().collect(); + let row_chars: Vec = row.chars().collect(); + row_chars + .windows(label_chars.len()) + .position(|w| w == label_chars.as_slice()) + .unwrap_or_else(|| panic!("label {label:?} not found in row {row:?}")) as u16 + } + + #[test] + fn small_modified_file_shows_gap_hunk_and_word_diff() { + // 12 lines of context around a single changed word, with more than 2*CONTEXT_LINES of + // untouched lines both before and after so a gap collapses on both edges. + let old = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nold word here\nl10\nl11\nl12\nl13\nl14\n"; + let new = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nnew word here\nl10\nl11\nl12\nl13\nl14\n"; let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .unstaged_file("small.txt", old, new) @@ -2126,8 +2827,8 @@ mod tests { content.join("\n") ); - // Word-diff emphasis: the changed word ("old"/"new") on the paired row should carry a - // strong background distinct from the rest of the line's subtle background. + // Word-diff emphasis: the changed word ("old"/"new") on the paired row should carry the + // edit background distinct from the rest of the line's line background. let changed_row_y = content .iter() .position(|line| line.contains("old word here")) @@ -2147,22 +2848,22 @@ mod tests { "expected the word-diff row to carry a background style distinct from plain context" ); - // The changed word ("old", bytes 0..3 → columns 4..7) must carry the STRONG emphasis + // The changed word ("old", bytes 0..3 → columns 4..7) must carry the EDIT emphasis // while the unchanged remainder of the same paired line ("word here", from column 8) - // stays subtle — three distinct backgrounds: strong word, subtle line, unstyled + // stays at the line wash — three distinct backgrounds: edit word, line, unstyled // context. This pins the compositor's span precedence (specific-over-whole-line); a - // first-match lookup renders the whole line subtle and only the ctx comparison above - // would still pass. + // first-match lookup renders the whole line at the line wash and only the ctx comparison + // above would still pass. let rest_cell = buf.cell((8, changed_row_y)).unwrap(); assert_ne!( word_cell.style().bg, rest_cell.style().bg, - "expected the changed word's strong bg to differ from the line's subtle bg" + "expected the changed word's edit bg to differ from the line's bg" ); assert_ne!( rest_cell.style().bg, ctx_cell.style().bg, - "expected the paired line's subtle bg to differ from plain context" + "expected the paired line's bg to differ from plain context" ); } @@ -2505,7 +3206,7 @@ mod tests { #[test] fn cursor_row_tint_composites_with_word_diff_emphasis_rather_than_replacing_it() { // The cursor starts on the file's first hunk (a word-diff paired row) after - // `open_current` — confirm the strong word-level bg and the whole-line subtle bg on that + // `open_current` — confirm the edit word-level bg and the whole-line bg on that // SAME row both stay visually distinct from each other even with the cursor tint // layered on top, i.e. the tint composites rather than flattening the existing emphasis. let old = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nold word here\nl10\nl11\nl12\nl13\nl14\n"; @@ -2532,7 +3233,7 @@ mod tests { let rest_bg = buf.cell((8, changed_row_y)).unwrap().style().bg; assert_ne!( word_bg, rest_bg, - "the cursor tint must not flatten the word-diff strong/subtle distinction on its \ + "the cursor tint must not flatten the word-diff edit/line distinction on its \ own row" ); } @@ -2589,11 +3290,48 @@ mod tests { ); } + #[test] + fn split_captions_rule_runs_the_full_pane_width_as_the_pane_divider() { + // The staged caption row is the only seam between the split's two panes — its rule must + // reach the right edge to read as a divider (dogfood feedback: the split lacked a rule + // like the outline↔diff and side-by-side ones). + 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(); + let buf = render_once(&mut app, 80, 24); + let content = buf_lines(&buf); + + for label in ["UNSTAGED", "STAGED"] { + let cap = caption_row(&content, label); + let row = content[cap].trim_end(); + assert_eq!( + row.chars().count(), + 80, + "{label} caption must span the full pane width, got: {row:?}" + ); + assert_eq!( + row.chars().last(), + Some('─'), + "{label} caption must end in the rule glyph, got: {row:?}" + ); + } + } + #[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. + // 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() @@ -2665,7 +3403,7 @@ mod tests { .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, subtle or strong depending on the word-diff split, but + // 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 @@ -2680,8 +3418,8 @@ mod tests { .bg; let t = Palette::dark(); - let dim_dels = [Some(t.del_staged_subtle), Some(t.del_staged_strong)]; - let bright_dels = [Some(t.del_subtle), Some(t.del_strong)]; + 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:?}" @@ -2709,8 +3447,8 @@ mod tests { .style() .bg; - let dim_adds = [Some(t.add_staged_subtle), Some(t.add_staged_strong)]; - let bright_adds = [Some(t.add_subtle), Some(t.add_strong)]; + 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:?}" @@ -2725,6 +3463,135 @@ mod tests { ); } + #[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() @@ -2903,9 +3770,9 @@ mod tests { ); } - // ── M5 CS2: winbar (locked decisions #8 + #9) ───────────────────────────── + // ── CS1 (`pane-headers`): outline header + diff header, replacing the old global winbar ──── - /// Build a two-committed-changeset stack for the winbar tests, hand-built the same way as + /// 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` + /// `ChangesetView::from_changeset_diff`): `cs-a` (`root..mid`, one file) then `cs-b` /// (`mid..head`, one file, `current` + `needs_restack`). @@ -2966,15 +3833,19 @@ mod tests { } #[test] - fn winbar_shows_changeset_position_title_path_and_restack_marker() { + fn outline_header_shows_changeset_position_title_and_restack_marker() { + // CS1: 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() .config("core.autocrlf", "false") .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); + assert!(app.outline_open(), "a two-changeset stack default-opens"); let buf = render_once(&mut app, 80, 20); - let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); + let header: String = (0..35).map(|x| cell_text(&buf, x, 0)).collect(); assert!( header.contains("[2/2]"), @@ -2988,55 +3859,107 @@ mod tests { header.contains("needs restack"), "expected the needs-restack marker, got: {header:?}" ); + } + + #[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 + // prefix — the outline's own header already carries that) — diff columns are x 36.. at + // this width. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + assert!(app.outline_open()); + + let buf = render_once(&mut app, 80, 20); + let header: String = (36..buf.area.width) + .map(|x| cell_text(&buf, x, 0)) + .collect(); + + assert!( + 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 + // PER-FILE (the old winbar only ever showed a changeset-total diffstat). + assert!( + header.contains("+1") && header.contains("-0"), + "expected a tight '+N -M' per-file diffstat fragment, got: {header:?}" + ); assert!( - header.contains("b.txt") && header.contains("(1/1)"), - "expected the active file's path and position, got: {header:?}" + !header.contains("[2/2]"), + "outline open: the diff header must not repeat the changeset-position prefix, \ + got: {header:?}" ); } #[test] - fn winbar_restack_marker_carries_the_warning_color() { + fn diff_header_carries_the_changeset_prefix_when_outline_closed() { + // CS1: 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() .config("core.autocrlf", "false") .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); + app.toggle_outline(); + assert!(!app.outline_open()); let buf = render_once(&mut app, 80, 20); let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); - let marker_x = header.find('⚠').expect("restack glyph present") as u16; - assert_eq!( - buf.cell((marker_x, 0)).unwrap().style().fg, - Some(Palette::dark().warn_fg), - "expected the restack glyph to carry the warning color, not the plain header color" + + assert!( + header.contains("[2/2]") && header.contains("cs-b"), + "expected the changeset position counter and active changeset's name, \ + got: {header:?}" + ); + // The diff header's changeset prefix is glyph-ONLY (no "needs restack" text — that + // fuller treatment is the outline header's, see `changeset_prefix_spans`'s doc comment). + assert!( + header.contains('⚠'), + "expected the needs-restack glyph, got: {header:?}" + ); + assert!( + header.contains("[1/1]") && header.contains("b.txt"), + "expected the active file's position and path, got: {header:?}" + ); + assert!( + header.contains("+1") && header.contains("-0"), + "expected the per-file diffstat fragment, got: {header:?}" ); } #[test] - fn winbar_shows_a_tight_diffstat_for_the_active_changeset() { - // CS4: the winbar previously showed no diffstat at all — cs-b adds a single line - // (`b.txt`, one-line file, committed with no prior content) with nothing deleted. + fn diff_header_restack_marker_carries_the_warning_color_when_outline_closed() { let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); + app.toggle_outline(); let buf = render_once(&mut app, 80, 20); let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); - assert!( - header.contains("+1") && header.contains("-0"), - "expected a tight '+N -M' diffstat fragment for cs-b's single added file, got: {header:?}" + let marker_x = header.find('⚠').expect("restack glyph present") as u16; + assert_eq!( + buf.cell((marker_x, 0)).unwrap().style().fg, + Some(Palette::dark().warn_fg), + "expected the restack glyph to carry the warning color, not the plain header color" ); } #[test] - fn winbar_nerd_mode_swaps_the_restack_marker_and_diffstat_glyphs_and_shows_a_file_icon() { + fn diff_header_nerd_mode_swaps_the_restack_marker_and_diffstat_glyphs_and_shows_a_file_icon() { let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); // cs-b: current + needs_restack + app.toggle_outline(); app.set_icon_mode(crate::icons::IconMode::Nerd); let buf = render_once(&mut app, 80, 20); @@ -3047,22 +3970,23 @@ mod tests { ); assert!( header.contains(super::NERD_DIFF_ADDED) && header.contains(super::NERD_DIFF_REMOVED), - "expected nerd diffstat glyphs in the winbar, got: {header:?}" + "expected nerd diffstat glyphs in the diff header, got: {header:?}" ); assert!( header.contains(crate::icons::icon_for_path("b.txt", false).0), - "expected the active file's (b.txt) devicons icon in the winbar, got: {header:?}" + "expected the active file's (b.txt) devicons icon in the diff header, got: {header:?}" ); } #[test] - fn winbar_uses_title_when_present() { + fn diff_header_uses_title_when_present() { let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); app.prev_changeset(); + app.toggle_outline(); let buf = render_once(&mut app, 80, 20); let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); @@ -3077,7 +4001,7 @@ mod tests { } #[test] - fn winbar_absent_for_a_lone_changeset() { + fn diff_header_lone_changeset_shows_file_counter_and_no_changeset_chrome() { let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") @@ -3093,34 +4017,137 @@ mod tests { ); assert!( !header.contains('⚠'), - "a lone changeset must not render the winbar chrome, got: {header:?}" + "a lone changeset must not render the changeset-prefix chrome, got: {header:?}" ); } #[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. - use git2::Repository; - use workon::{Changeset, ChangesetSpan}; - - use crate::app::ChangesetView; - - let committed = "l1\nold word here\nl3\n"; - let head_content = "l1\nnew word here\nl3\n"; + 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 + // carries a per-file diffstat in every state, including this one. let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") .build() .unwrap(); - let base = fixture - .commit("main") - .file("f.txt", committed) - .create("base") + let mut app = app_from_fixture(&fixture); + + let buf = render_once(&mut app, 80, 20); + let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); + // The fixture only ADDS a line ("CHANGED", appended after the unchanged "one") — nothing + // is deleted, so the per-file diffstat is `+1 -0`. + assert!( + header.contains("+1") && header.contains("-0"), + "expected a per-file '+N -M' diffstat fragment on the lone-changeset header, \ + got: {header:?}" + ); + } + + #[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 + // 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; + use git2::Repository; + use workon::{Changeset, ChangesetSpan}; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let root = fixture + .commit("main") + .file("root.txt", "r\n") + .create("root") + .unwrap(); + let mid = fixture + .commit("main") + .file("a.txt", "a\n") + .create("mid") + .unwrap(); + let repo = fixture.repo().unwrap(); + + let cs_a = Changeset { + name: "cs-a".to_string(), + span: ChangesetSpan::Committed { + base: root, + head: mid, + }, + title: None, + current: false, + needs_restack: false, + }; + let cs_b = Changeset { + name: "cs-b".to_string(), + span: ChangesetSpan::Committed { + base: mid, + head: mid, + }, + title: None, + current: true, + needs_restack: false, + }; + let view_a = ChangesetView::from_changeset_diff( + cs_a.clone(), + crate::acquire::diff_changeset(repo, &cs_a).unwrap(), + ); + let view_b = ChangesetView::pending(cs_b); + + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view_a, view_b]); + assert!(app.is_current_pending()); + + // Outline open (this stack's default): a blank diff-header row, never "[1/0]". + assert!(app.outline_open()); + let buf = render_once(&mut app, 80, 20); + let header: String = (36..buf.area.width) + .map(|x| cell_text(&buf, x, 0)) + .collect(); + assert!( + !header.contains("[1/0]"), + "must never show a misleading file counter, got: {header:?}" + ); + + // Outline closed: the changeset prefix alone, still no file counter. + app.toggle_outline(); + let buf = render_once(&mut app, 80, 20); + let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); + assert!( + header.contains("cs-b"), + "expected the changeset prefix naming the pending changeset, got: {header:?}" + ); + assert!( + !header.contains("[1/0]"), + "must never show a misleading file counter, got: {header:?}" + ); + } + + #[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. + use git2::Repository; + use workon::{Changeset, ChangesetSpan}; + + use crate::app::ChangesetView; + + let committed = "l1\nold word here\nl3\n"; + let head_content = "l1\nnew word here\nl3\n"; + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("f.txt", committed) + .create("base") .unwrap(); let head = fixture .commit("main") @@ -3160,8 +4187,8 @@ mod tests { let add_bg = buf.cell((new_content_x, row_y)).unwrap().style().bg; let t = Palette::dark(); - let bright_adds = [Some(t.add_subtle), Some(t.add_strong)]; - let dim_adds = [Some(t.add_staged_subtle), Some(t.add_staged_strong)]; + let bright_adds = [Some(t.add_line_bg), Some(t.add_edit_bg)]; + let dim_adds = [Some(t.add_staged_line_bg), Some(t.add_staged_edit_bg)]; assert!( bright_adds.contains(&add_bg), "expected a committed changeset's Add cell to render the plain (bright) pair, \ @@ -3352,7 +4379,10 @@ mod tests { } #[test] - fn winbar_shows_the_pan_offset_indicator_once_panned() { + 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 + // (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() .config("core.autocrlf", "false") .build() @@ -3361,22 +4391,24 @@ mod tests { assert_eq!(app.hscroll, 0); let buf_unpanned = render_once(&mut app, 80, 20); - let header_unpanned: String = (0..buf_unpanned.area.width) + let header_unpanned: String = (36..buf_unpanned.area.width) .map(|x| cell_text(&buf_unpanned, x, 0)) .collect(); assert!( !header_unpanned.contains('»'), - "no indicator at column 0, got: {header_unpanned:?}" + "no indicator at hscroll 0, got: {header_unpanned:?}" ); - // The winbar test's fixture files are tiny (`a\n`/`b\n`) — nowhere near wide enough for + // The fixture files are tiny (`a\n`/`b\n`) — nowhere near wide enough for // `hscroll_right` to actually move `hscroll` off `0`. This checks the indicator's own // render logic, not the pan mechanics (covered separately in `app.rs`), so setting the // field directly is the more honest test: the indicator must key off `App::hscroll` // exactly, with no dependency on how it got there. app.hscroll = 42; let buf = render_once(&mut app, 80, 20); - let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); + let header: String = (36..buf.area.width) + .map(|x| cell_text(&buf, x, 0)) + .collect(); assert!( header.contains("»42"), "expected the pan offset indicator, got: {header:?}" @@ -3562,16 +4594,7 @@ mod tests { .iter() .position(|r| r.contains("cs-b")) .expect("cs-b's header row present (it has no title, so falls back to its name)"); - // `String::find` returns a BYTE offset, not a display column — the row has multi-byte - // glyphs (`•`/`⚠`) ahead of/around the label, so a byte offset would target the wrong - // cell. Every rendered cell here is exactly one column wide, so a `chars()` (not byte) - // position IS the display column. - let label_chars: Vec = "cs-b".chars().collect(); - let row_chars: Vec = content[row].chars().collect(); - let label_x = row_chars - .windows(label_chars.len()) - .position(|w| w == label_chars.as_slice()) - .expect("cs-b's label text present in its own header row") as u16; + let label_x = find_label_x(&content[row], "cs-b"); assert_eq!( buf.cell((label_x, row as u16 + 1)).unwrap().style().fg, Some(Palette::dark().heading_fg), @@ -3579,6 +4602,110 @@ mod tests { ); } + #[test] + fn outline_header_truncates_to_the_pane_width() { + // CS1: `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; + use git2::Repository; + use workon::{Changeset, ChangesetSpan}; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let root = fixture + .commit("main") + .file("root.txt", "r\n") + .create("root") + .unwrap(); + let mid = fixture + .commit("main") + .file("a.txt", "a\n") + .create("mid") + .unwrap(); + let head = fixture + .commit("main") + .file("b.txt", "b\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + + let cs_a = Changeset { + name: "cs-a".to_string(), + span: ChangesetSpan::Committed { + base: root, + head: mid, + }, + title: None, + current: false, + needs_restack: false, + }; + let cs_b = Changeset { + name: "x".repeat(100), + span: ChangesetSpan::Committed { base: mid, head }, + title: None, + current: true, + needs_restack: false, + }; + let view_a = ChangesetView::from_changeset_diff( + cs_a.clone(), + crate::acquire::diff_changeset(repo, &cs_a).unwrap(), + ); + let view_b = ChangesetView::from_changeset_diff( + cs_b.clone(), + crate::acquire::diff_changeset(repo, &cs_b).unwrap(), + ); + + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view_a, view_b]); + app.open_current(); + assert!(app.outline_open()); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + assert_ne!( + cell_text(&buf, 34, 0), + " ", + "expected the truncated label to reach all the way to the outline's last column" + ); + assert_eq!( + cell_text(&buf, 35, 0), + "│", + "the outline header must truncate to the pane's own width, not bleed into the \ + divider column" + ); + } + + #[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), + // 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 + // items actually start. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + assert!(app.outline_open()); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let row0 = outline_row(&buf, 0); + assert!( + !row0.contains('\u{2022}'), + "the outline's OWN header never shows the current-changeset marker, got: {row0:?}" + ); + let row1 = outline_row(&buf, 1); + assert!( + row1.contains('\u{2022}'), + "the first outline ITEM row (cs-b's Header row, which IS current) must start at \ + y=1, got: {row1:?}" + ); + } + #[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 @@ -3600,11 +4727,12 @@ mod tests { app.focus_outline(); let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); - // Skip y=0: the full-width winbar spans every column (including the body's 36.. slice), - // and it too names the current changeset (cs-b) — same false-positive risk as the outline - // tests above. `body_rows`' index `i` is buffer row `i + 1` (the skip), so every `buf` - // query below adds 1 back. - let body_rows: Vec = (1..buf.area.height) + // CS1: 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 + // that false-positive, same as the outline tests above. + let body_rows: Vec = (0..buf.area.height) .map(|y| { (36..buf.area.width) .map(|x| cell_text(&buf, x, y)) @@ -3620,20 +4748,17 @@ mod tests { .iter() .position(|r| r.contains("cs-b")) .expect("summary panel's title (cs-b's label) present"); - // `String::find` is a BYTE offset, not a display column (the title carries a multi-byte - // `•` marker ahead of the label, since cs-b is `current`) — a `chars()` position over the - // 36.. slice IS the column offset within that slice (every cell here is one column wide), - // so add the slice's own start column (36) back to get the absolute buffer column. - let label_chars: Vec = "cs-b".chars().collect(); - let row_chars: Vec = body_rows[row].chars().collect(); - let label_x = row_chars - .windows(label_chars.len()) - .position(|w| w == label_chars.as_slice()) - .expect("cs-b's label text present in the summary panel's title") - as u16 - + 36; assert_eq!( - buf.cell((label_x, row as u16 + 1)).unwrap().style().fg, + row, 0, + "the summary panel's title now paints the diff pane's header row (y=0), got row \ + {row} instead:\n{joined}" + ); + // `find_label_x` returns a column offset within the 36.. slice it's given (every cell here + // is one column wide, so a `chars()` position IS the display column) — add the slice's own + // start column (36) back to get the absolute buffer column. + let label_x = find_label_x(&body_rows[row], "cs-b") + 36; + assert_eq!( + buf.cell((label_x, row as u16)).unwrap().style().fg, Some(Palette::dark().foreground), "the summary panel's title must keep its plain foreground look, not the outline's \ heading accent" @@ -4212,9 +5337,9 @@ mod tests { } #[test] - fn outline_unstaged_file_renders_the_y_column_letter_in_del_strong() { + fn outline_unstaged_file_renders_the_y_column_letter_in_del_fg() { // Unstaged-only (worktree change, no staged one): X is the placeholder, Y carries the - // change letter in del_strong (git convention: worktree column is red). + // change letter in del_fg (git convention: worktree column is red). let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .unstaged_file("a.rs", "one\n", "one\nCHANGED\n") @@ -4240,16 +5365,16 @@ mod tests { ); assert_eq!( buf.cell((x + 1, y)).unwrap().style().fg, - Some(Palette::dark().del_strong), - "expected the Y column's Modified letter to carry theme.del_strong" + Some(Palette::dark().del_fg), + "expected the Y column's Modified letter to carry theme.del_fg" ); } #[test] - fn outline_fully_staged_file_renders_the_x_column_letter_in_add_strong() { + fn outline_fully_staged_file_renders_the_x_column_letter_in_add_fg() { // `staged_file` writes+stages a brand-new path (Added, not Modified — there's no prior // commit for it to modify). Fully staged (index change, no worktree one): X carries the - // letter in add_strong, Y is the placeholder. + // letter in add_fg, Y is the placeholder. let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .staged_file("a.rs", "new content\n") @@ -4269,8 +5394,8 @@ mod tests { ); assert_eq!( buf.cell((x, y)).unwrap().style().fg, - Some(Palette::dark().add_strong), - "expected the X column's Added letter to carry theme.add_strong" + Some(Palette::dark().add_fg), + "expected the X column's Added letter to carry theme.add_fg" ); assert_eq!( buf.cell((x + 1, y)).unwrap().style().fg, @@ -4282,7 +5407,7 @@ mod tests { #[test] fn outline_partially_staged_file_renders_mm_with_green_x_and_red_y() { // Partially staged (both a staged AND an unstaged change): both columns show the change - // letter, X in add_strong (green), Y in del_strong (red). + // letter, X in add_fg (green), Y in del_fg (red). let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .partially_staged_file("a.rs", "one\n", "one\nSTAGED\n", "one\nSTAGED\nWORKTREE\n") @@ -4302,13 +5427,13 @@ mod tests { ); assert_eq!( buf.cell((x, y)).unwrap().style().fg, - Some(Palette::dark().add_strong), - "expected the X (staged) column's letter to carry theme.add_strong" + Some(Palette::dark().add_fg), + "expected the X (staged) column's letter to carry theme.add_fg" ); assert_eq!( buf.cell((x + 1, y)).unwrap().style().fg, - Some(Palette::dark().del_strong), - "expected the Y (worktree) column's letter to carry theme.del_strong" + Some(Palette::dark().del_fg), + "expected the Y (worktree) column's letter to carry theme.del_fg" ); } @@ -4416,7 +5541,7 @@ mod tests { } #[test] - fn outline_committed_added_and_deleted_files_render_add_strong_and_del_strong() { + fn outline_committed_added_and_deleted_files_render_add_fg_and_del_fg() { let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .build() @@ -4486,8 +5611,8 @@ mod tests { .unwrap() .style() .fg, - Some(Palette::dark().add_strong), - "expected a committed Added file's letter to carry theme.add_strong" + Some(Palette::dark().add_fg), + "expected a committed Added file's letter to carry theme.add_fg" ); let deleted_row_idx = content @@ -4504,8 +5629,8 @@ mod tests { .unwrap() .style() .fg, - Some(Palette::dark().del_strong), - "expected a committed Deleted file's letter to carry theme.del_strong" + Some(Palette::dark().del_fg), + "expected a committed Deleted file's letter to carry theme.del_fg" ); } @@ -4579,6 +5704,74 @@ mod tests { ); } + #[test] + fn icon_mode_nerd_collapses_the_file_icon_color_to_foreground_under_a_colorless_theme() { + // The `no-color-mono` finding this guards: `icons::icon_for_path`'s hardcoded per-filetype + // `Rgb` is palette-EXTERNAL, so it must be collapsed to `foreground` by the render.rs paint + // site itself when `Palette::colorless` is set — `mono()`'s own fields (already `Reset`) + // can't do this for it. Companion to the theme.rs-level `only_mono_sets_colorless` test. + use git2::Repository; + use workon::{Changeset, ChangesetSpan}; + + use crate::app::ChangesetView; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let root = fixture + .commit("main") + .file("root.txt", "r\n") + .create("root") + .unwrap(); + let head = fixture + .commit("main") + .file("main.rs", "fn main() {}\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + let cs = Changeset { + name: "cs".to_string(), + span: ChangesetSpan::Committed { base: root, head }, + title: None, + current: true, + needs_restack: false, + }; + let view = ChangesetView::from_changeset_diff( + cs.clone(), + crate::acquire::diff_changeset(repo, &cs).unwrap(), + ); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view]); + app.open_current(); + if !app.outline_open() { + app.toggle_outline(); + } + app.set_icon_mode(crate::icons::IconMode::Nerd); + + let mono = Palette::mono(false); + let buf = render_once_themed(&mut app, OUTLINE_TEST_WIDTH, 20, &mono); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + + let (row_idx, row) = content + .iter() + .enumerate() + .skip(1) // y=0 is the winbar + .find(|(_, r)| r.contains("main.rs")) + .expect("main.rs file row present"); + let icon = crate::icons::icon_for_path("main.rs", false).0; + let icon_x = row + .chars() + .position(|c| c == icon) + .expect("icon glyph present in the file row") as u16; + + assert_eq!( + buf.cell((icon_x, row_idx as u16)).unwrap().style().fg, + Some(mono.foreground), + "icon fg must collapse to `foreground` under a colorless theme, got row: {row:?}" + ); + } + #[test] fn icon_mode_none_renders_neither_icon() { let fixture = FixtureBuilder::new() @@ -4734,26 +5927,78 @@ mod tests { } #[test] - fn focused_header_selection_renders_the_summary_panel_instead_of_the_diff() { + fn summary_header_shows_dir_title_and_body_drops_duplicate() { + // CS1: `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() .config("core.autocrlf", "false") .build() .unwrap(); - let mut app = two_committed_changesets_app(&fixture); - assert!(app.outline_open(), "a two-changeset stack default-opens"); - // Default is open+unfocused; two toggles (close, reopen) focuses it — same idiom - // `outline_cursor_row_carries_cursor_background_when_focused` uses. Construction's - // `sync_outline_to_current` already parked the cursor on cs-b's (the `current` - // changeset's) own File row, not a Header — move it onto cs-b's Header explicitly. - app.toggle_outline(); - app.toggle_outline(); - assert!(app.outline_open() && app.outline_focused()); - let header_idx = app + let mut app = changeset_with_nested_paths(&fixture); + app.focus_outline(); // opens (a lone changeset defaults closed) and focuses + app.outline_cycle_mode(); // Stack -> StackTree + app.outline_cycle_mode(); // StackTree -> Flat + app.outline_cycle_mode(); // Flat -> Tree, so a Dir row exists to focus + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); + let dir_idx = app .outline_items() .iter() - .position(|it| matches!(it, OutlineItem::Header { cs_idx: 1, .. })) - .expect("cs-b's header row present in Stack mode") as i64; - // A Header row never jumps the diff on `outline_move_by` (only a File row does — see its + .position(|it| matches!(it, OutlineItem::Dir { .. })) + .expect("a Dir row present in Tree mode") as i64; + let delta = dir_idx - app.outline_cursor() as i64; + app.outline_move_by(delta); + assert!(matches!( + app.outline_items()[app.outline_cursor()], + OutlineItem::Dir { .. } + )); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let header: String = (36..buf.area.width) + .map(|x| cell_text(&buf, x, 0)) + .collect(); + assert!( + header.trim_end().ends_with("src/"), + "expected the diff pane's header row to carry the dir summary's title, got: {header:?}" + ); + + // The exact title text ("src/", nothing else) must not reappear as a whole body line — + // a per-file row like "src/a.txt +1 -0" legitimately CONTAINS "src/" as a substring, so + // this checks for an exact-line match, not a substring. + for y in 1..buf.area.height { + let row: String = (36..buf.area.width) + .map(|x| cell_text(&buf, x, y)) + .collect(); + assert_ne!( + row.trim_end(), + "src/", + "the summary panel's body must not duplicate the title as its own line, \ + got row {y}: {row:?}" + ); + } + } + + #[test] + fn focused_header_selection_renders_the_summary_panel_instead_of_the_diff() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + assert!(app.outline_open(), "a two-changeset stack default-opens"); + // Default is open+unfocused; two toggles (close, reopen) focuses it — same idiom + // `outline_cursor_row_carries_cursor_background_when_focused` uses. Construction's + // `sync_outline_to_current` already parked the cursor on cs-b's (the `current` + // changeset's) own File row, not a Header — move it onto cs-b's Header explicitly. + app.toggle_outline(); + app.toggle_outline(); + assert!(app.outline_open() && app.outline_focused()); + let header_idx = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::Header { cs_idx: 1, .. })) + .expect("cs-b's header row present in Stack mode") as i64; + // A Header row never jumps the diff on `outline_move_by` (only a File row does — see its // doc comment), so a single relative move onto it is side-effect-free. let delta = header_idx - app.outline_cursor() as i64; app.outline_move_by(delta); @@ -4865,6 +6110,41 @@ mod tests { ); } + #[test] + fn content_rows_carry_the_painted_canvas_background() { + // The empty-screen canvas tests above miss the real dogfood surface: rows the diff body + // actually writes. A context line's untinted cells (its text and the padding after it) + // must sit on the theme's own canvas, not the terminal default — `theme light` in a dark + // terminal otherwise renders every content row on a black canvas. + // A partially staged file renders the split UNSTAGED/STAGED view — the everyday dogfood + // shape, and a different render path from the single-pane view the tests above exercise. + let committed = "l1\nl2\nl3\nold word here\nl5\nl6\n"; + let staged = "l1\nl2\nl3\nstaged word here\nl5\nl6\n"; + let workdir = "l1\nl2\nl3\nnew word here\nl5\nl6\n"; + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .partially_staged_file("small.txt", committed, staged, workdir) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let theme = Palette::light(); + let buf = render_once_themed(&mut app, 60, 20, &theme); + + let content = buf_lines(&buf); + let context_y = content + .iter() + .position(|line| line.contains("l2")) + .expect("context row present") as u16; + let x = content[context_y as usize].find("l2").unwrap() as u16; + let cell = buf.cell((x, context_y)).unwrap(); + assert_eq!( + cell.style().bg, + Some(theme.background), + "expected a context-row content cell to carry the painted canvas background" + ); + } + #[test] fn dark_theme_paints_the_canvas_with_the_dark_background() { let fixture = FixtureBuilder::new() @@ -4927,4 +6207,659 @@ mod tests { "the cursor tint must be visually distinct from the flat painted canvas" ); } + + // ── focused-pane-header (CS1): 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 + /// no bold, or vice versa, would both be bugs). + fn label_style_at(buf: &Buffer, x: u16, y: u16) -> (Option, bool) { + let style = buf.cell((x, y)).unwrap().style(); + (style.fg, style.add_modifier.contains(Modifier::BOLD)) + } + + #[test] + fn startup_state_lights_the_diff_header_not_the_outline_header() { + // 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)`). + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + assert!( + app.outline_open() && !app.outline_focused(), + "locked startup default" + ); + assert_eq!( + app.effective_zoom_for(app.current), + EffectiveZoom::Single(Role::Combined) + ); + + let theme = Palette::dark(); + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content = buf_lines(&buf); + + // Outline header's own title (row 0) names the current changeset ("cs-b") — dim, no bold. + let outline_x = find_label_x(&content[0], "cs-b"); + assert_eq!( + label_style_at(&buf, outline_x, 0), + (Some(theme.dim), false), + "outline header must stay dim while the outline is unfocused" + ); + + // Diff header's own label (row 0, right of the divider) names the file ("b.txt") — lit. + let diff_x = find_label_x(&content[0], "b.txt"); + assert_eq!( + label_style_at(&buf, diff_x, 0), + (Some(theme.pane_header_focused_fg), true), + "diff header must be lit at startup, since focus starts on the diff side" + ); + } + + #[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 + // 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") + .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, + "a partially-staged file defaults to a Split render" + ); + app.focus_outline(); + assert!(app.outline_open() && app.outline_focused()); + + let theme = Palette::dark(); + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 24); + let content = buf_lines(&buf); + + // `app_from_fixture`'s lone changeset is the synthetic uncommitted layer, whose + // `display_label` is always "Uncommitted changes" (see `crate::app::display_label`), not + // the file's own name. + let outline_x = find_label_x(&content[0], "Uncommitted changes"); + assert_eq!( + label_style_at(&buf, outline_x, 0), + (Some(theme.pane_header_focused_fg), true), + "outline header must be lit while the outline has focus" + ); + + let unstaged_row = content + .iter() + .position(|line| line.contains("UNSTAGED")) + .expect("unstaged caption present"); + let staged_row = content + .iter() + .position(|line| line.contains("STAGED") && !line.contains("UNSTAGED")) + .expect("staged caption present"); + let unstaged_x = find_label_x(&content[unstaged_row], "UNSTAGED"); + let staged_x = find_label_x(&content[staged_row], "STAGED"); + assert_eq!( + label_style_at(&buf, unstaged_x, unstaged_row as u16), + (Some(theme.dim), false), + "the unstaged caption must stay dim while the outline holds focus" + ); + assert_eq!( + label_style_at(&buf, staged_x, staged_row as u16), + (Some(theme.dim), false), + "the staged caption must stay dim while the outline holds focus" + ); + } + + #[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 + // 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() + .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); + assert!(!app.outline_focused()); + assert_eq!( + app.split_focus_role(), + Role::Unstaged, + "default split focus" + ); + + let theme = Palette::dark(); + + let check = |app: &mut App, lit_label: &str, dim_label: &str| { + let buf = render_once(app, OUTLINE_TEST_WIDTH, 24); + let content = buf_lines(&buf); + let lit_row = caption_row(&content, lit_label); + let dim_row = caption_row(&content, dim_label); + let lit_x = find_label_x(&content[lit_row], lit_label); + let dim_x = find_label_x(&content[dim_row], dim_label); + assert_eq!( + label_style_at(&buf, lit_x, lit_row as u16), + (Some(theme.pane_header_focused_fg), true), + "{lit_label} should be the lit label" + ); + assert_eq!( + label_style_at(&buf, dim_x, dim_row as u16), + (Some(theme.dim), false), + "{dim_label} should stay dim" + ); + // The diff pane's own header (row 0) stays dim under Split, regardless of which half + // has focus — there is no single-file label to light while two panes are showing. + let file_x = find_label_x(&content[0], "f.txt"); + assert_eq!( + label_style_at(&buf, file_x, 0), + (Some(theme.dim), false), + "the diff header must stay dim under a Split zoom" + ); + }; + + check(&mut app, "UNSTAGED", "STAGED"); + app.toggle_split_focus(); + assert_eq!(app.split_focus_role(), Role::Staged); + check(&mut app, "STAGED", "UNSTAGED"); + } + + #[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 + // of the two sub-diffs (here, unstaged-only) — no captions render at all, so the diff + // header itself must be the lit label, exactly as the plain-Single case above. + let old = "l1\nl2\nl3\n"; + let new = "l1\nCHANGED\nl3\n"; + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("only.txt", old, new) + .build() + .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_eq!( + app.effective_zoom_for(app.current), + EffectiveZoom::Single(Role::Unstaged), + "collapsed down to a single pane — no staged sub-diff to pair it with" + ); + + let theme = Palette::dark(); + let buf = render_once(&mut app, 60, 20); + let content = buf_lines(&buf); + for line in &content { + assert!( + !line.contains("UNSTAGED") && !line.contains("STAGED"), + "a collapsed Single zoom must not render split captions, got: {line:?}" + ); + } + let file_x = find_label_x(&content[0], "only.txt"); + assert_eq!( + label_style_at(&buf, file_x, 0), + (Some(theme.pane_header_focused_fg), true), + "the diff header must be the lit label once Split has collapsed to Single" + ); + } + + #[test] + fn split_zoom_short_area_fallback_lights_the_diff_header_not_a_caption() { + // Gotcha: `render_body_split`'s own short-area fallback (`area.height < 4`) renders only + // the focused pane and returns before either caption is drawn — no split caption survives + // to be the frame's lit label, so `render_body` must light the diff header instead. A + // 5-row frame leaves a diff pane body area of height 3 after the header carve-out (frame + // height 5 - footer 1 = body/diff area height 4, minus the diff header's own 1 row = 3), + // which is under the `render_body_split` fallback's `< 4` threshold. + 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); + assert!(!app.outline_focused()); + + let theme = Palette::dark(); + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 5); + let content = buf_lines(&buf); + + for line in &content { + assert!( + !line.contains("UNSTAGED") && !line.contains("STAGED"), + "the short-area fallback must not render split captions, got: {line:?}" + ); + } + let file_x = find_label_x(&content[0], "f.txt"); + assert_eq!( + label_style_at(&buf, file_x, 0), + (Some(theme.pane_header_focused_fg), true), + "the diff header must be the lit label once the split fallback drops both captions" + ); + } + + #[test] + fn no_color_bold_is_the_only_focus_differentiator() { + // Locked decision #3: 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 + // alone when actually painting a frame under that palette. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + assert!(!app.outline_focused()); + + let theme = Palette::mono(false); + let buf = render_once_themed(&mut app, OUTLINE_TEST_WIDTH, 20, &theme); + let content = buf_lines(&buf); + + let outline_x = find_label_x(&content[0], "cs-b"); + let (outline_fg, outline_bold) = label_style_at(&buf, outline_x, 0); + let diff_x = find_label_x(&content[0], "b.txt"); + let (diff_fg, diff_bold) = label_style_at(&buf, diff_x, 0); + + assert_eq!(outline_fg, Some(Color::Reset)); + assert_eq!(diff_fg, Some(Color::Reset)); + assert_eq!( + outline_fg, diff_fg, + "color alone carries no distinction under NO_COLOR" + ); + assert!( + !outline_bold, + "the dim (unfocused) outline header must not be bold" + ); + assert!( + diff_bold, + "the lit (focused) diff header must stay bold under NO_COLOR" + ); + } + + // ── unfocused-cursor-wash (CS1): 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 + // dim unfocused wash, not full `cursor_bg`, whenever the outline (not the diff) holds + // focus. + let old = "l1\nl2\nl3\n"; + let new = "l1\nCHANGED\nl3\n"; + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("only.txt", old, new) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert_eq!( + app.effective_zoom_for(app.current), + EffectiveZoom::Single(Role::Unstaged) + ); + app.focus_outline(); + assert!(app.outline_open() && app.outline_focused()); + + let theme = Palette::dark(); + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + + // Row 0 of the diff pane's own rect is its header; content starts at row 1. The cursor + // row lands at `1 + (cursor - scroll)`, neither of which `render_body`'s Single-zoom arm + // mutates (it only reads them), so the values read back after rendering are exactly what + // painted the frame. + let cursor_y = (1 + app.cursor - app.scroll) as u16; + let cell = buf.cell((37, cursor_y)).unwrap(); + assert_eq!( + cell.style().bg, + Some(theme.cursor_unfocused_bg), + "the diff cursor must dim to the unfocused wash while the outline holds focus" + ); + assert_ne!( + cell.style().bg, + Some(theme.cursor_bg), + "the diff cursor must NOT show the full focused wash while the outline holds focus" + ); + } + + #[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 + // 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`). + 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); + assert_eq!( + app.split_focus_role(), + Role::Unstaged, + "default split focus" + ); + + // Move the (currently focused) unstaged pane's cursor onto its changed row, before the + // outline takes focus — the staged pane's `alt` cursor is untouched, so it stays at + // `reset_panes`'s first-hunk reseat: the staged pane renders the base->staged diff, whose + // only change is "beta" -> "BETAEDIT" (row 1), not row 0 ("alpha"). ("GAMMAEDIT" is the + // UNSTAGED pane's own hunk — the index->workdir diff — and never appears in the staged + // pane at all.) + app.cursor = 1; + app.derive_scroll(); + app.focus_outline(); + assert!(app.outline_open() && app.outline_focused()); + + let theme = Palette::dark(); + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 24); + let content = buf_lines(&buf); + + let unstaged_caption_row = caption_row(&content, "UNSTAGED"); + let staged_caption_row = caption_row(&content, "STAGED"); + + // "BETAEDIT" appears once in EACH pane at row index 1 — the unstaged pane's unchanged + // CONTEXT line (its own hunk is gamma -> GAMMAEDIT, at row 2, which `app.cursor` is never + // set to here) and the staged pane's actual hunk (its `alt.cursor`, from `reset_panes`'s + // first-hunk reseat) — so each search is bounded to its own pane's row range to + // disambiguate which "BETAEDIT" it's finding. Bounded starting at column 36 to skip the + // outline to the left of the diff panes. + let unstaged_cursor_y = find_row( + &buf, + 36, + unstaged_caption_row + 1, + staged_caption_row, + "BETAEDIT", + ); + let staged_cursor_y = find_row(&buf, 36, staged_caption_row + 1, content.len(), "BETAEDIT"); + + // Same left/divider geometry `render_pane_sbs` computes for a `diff_w`-wide pane at + // `OUTLINE_TEST_WIDTH` (outline `0..35` + 1-col divider, diff pane `36..`). + let diff_x0 = 36u16; + let diff_w = OUTLINE_TEST_WIDTH - diff_x0; + let left_w = diff_w.saturating_sub(1) / 2; + let div_x = diff_x0 + left_w; + + let unstaged_cell = buf.cell((diff_x0 + 1, unstaged_cursor_y)).unwrap(); + assert_eq!( + unstaged_cell.style().bg, + Some(theme.cursor_unfocused_bg), + "the unstaged half's cursor must dim while the outline holds focus" + ); + let staged_cell = buf.cell((diff_x0 + 1, staged_cursor_y)).unwrap(); + assert_eq!( + staged_cell.style().bg, + Some(theme.cursor_unfocused_bg), + "the staged half's cursor must dim while the outline holds focus" + ); + + let divider_cell = buf.cell((div_x, unstaged_cursor_y)).unwrap(); + assert_eq!( + divider_cell.style().bg, + Some(theme.cursor_unfocused_bg), + "the divider cell on a dimmed cursor row must carry the same dim wash, not stay bright" + ); + } + + #[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` + // 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). + 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); + assert!(!app.outline_focused()); + assert_eq!( + app.split_focus_role(), + Role::Unstaged, + "default split focus" + ); + if app.outline_open() { + app.toggle_outline(); // force closed — a clean full-width diff pane, no outline offset + } + + // Land the (currently focused) unstaged pane's cursor on row 1 (a context line in the + // unstaged/index->workdir diff — its own hunk, gamma -> GAMMAEDIT, is row 2), then flip + // focus to the staged half — `toggle_split_focus` swaps `cursor`/`scroll` with `alt`, so + // that position becomes the unstaged half's REMEMBERED `alt` cursor. The staged half's OWN + // `alt` (untouched since `reset_panes`'s first-hunk reseat) becomes the newly-focused + // `cursor`: the staged pane renders the base->staged diff, whose only hunk is + // "beta" -> "BETAEDIT", also row 1 — coincidentally the same row index, different text. + app.cursor = 1; + app.derive_scroll(); + app.toggle_split_focus(); + assert_eq!(app.split_focus_role(), Role::Staged); + + let theme = Palette::dark(); + let buf = render_once(&mut app, 60, 20); + let content = buf_lines(&buf); + + let unstaged_caption_row = caption_row(&content, "UNSTAGED"); + let staged_caption_row = caption_row(&content, "STAGED"); + + // "BETAEDIT" appears once in EACH pane at row index 1 (see the comment above) — bounded + // per pane to disambiguate which one a given search lands on. No outline offset here (the + // outline was force-closed above), so the search starts at column 0. + let unstaged_cursor_y = find_row( + &buf, + 0, + unstaged_caption_row + 1, + staged_caption_row, + "BETAEDIT", + ); + let staged_cursor_y = find_row(&buf, 0, staged_caption_row + 1, content.len(), "BETAEDIT"); + + let unstaged_cell = buf.cell((1, unstaged_cursor_y)).unwrap(); + assert_eq!( + unstaged_cell.style().bg, + Some(theme.cursor_unfocused_bg), + "the just-unfocused half's remembered cursor must show the dim wash" + ); + assert_ne!(unstaged_cell.style().bg, Some(theme.cursor_bg)); + + let staged_cell = buf.cell((1, staged_cursor_y)).unwrap(); + assert_eq!( + staged_cell.style().bg, + Some(theme.cursor_bg), + "the newly-focused half must show the full cursor wash" + ); + } + + // ── header-chrome-follows-focus (CS1): 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). + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + assert!(app.outline_open() && !app.outline_focused()); + + let theme = Palette::dark(); + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content = buf_lines(&buf); + let counter_x = find_label_x(&content[0], "[2/2]"); + assert_eq!( + label_style_at(&buf, counter_x, 0), + (Some(theme.dim), false), + "the outline header counter must dim alongside the label while unfocused" + ); + + app.focus_outline(); + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content = buf_lines(&buf); + let counter_x = find_label_x(&content[0], "[2/2]"); + assert_eq!( + label_style_at(&buf, counter_x, 0), + (Some(theme.pane_header_focused_fg), true), + "the outline header counter must light alongside the label while focused" + ); + } + + #[test] + fn diff_header_file_counter_follows_the_labels_focus_toggle() { + // Same toggle as the outline header's counter above, for the diff header's own + // `[fidx/nfiles]` counter ([`super::file_segment_spans`]). + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + assert!(app.outline_open() && !app.outline_focused()); + + let theme = Palette::dark(); + let buf = render_once(&mut app, 80, 20); + let content = buf_lines(&buf); + let counter_x = find_label_x(&content[0], "[1/1]"); + assert_eq!( + label_style_at(&buf, counter_x, 0), + (Some(theme.pane_header_focused_fg), true), + "the diff header counter must light alongside the label while the diff has focus" + ); + + app.focus_outline(); + let buf = render_once(&mut app, 80, 20); + let content = buf_lines(&buf); + let counter_x = find_label_x(&content[0], "[1/1]"); + assert_eq!( + label_style_at(&buf, counter_x, 0), + (Some(theme.dim), false), + "the diff header counter must dim alongside the label once focus leaves the diff" + ); + } + + #[test] + fn outline_header_diffstat_colors_stay_semantic_across_focus_toggle() { + // Locked decision #2: 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") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + let theme = Palette::dark(); + + let unfocused_buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let unfocused_content = buf_lines(&unfocused_buf); + let add_x = find_label_x(&unfocused_content[0], "+1"); + let unfocused_add = label_style_at(&unfocused_buf, add_x, 0); + + app.focus_outline(); + let focused_buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let focused_content = buf_lines(&focused_buf); + let add_x = find_label_x(&focused_content[0], "+1"); + let focused_add = label_style_at(&focused_buf, add_x, 0); + + assert_eq!( + unfocused_add, focused_add, + "the outline header's diffstat span must not change with focus" + ); + assert_eq!( + unfocused_add, + (Some(theme.add_fg), true), + "the diffstat span keeps its own semantic color and bold regardless of focus" + ); + } + + #[test] + fn changeset_prefix_text_follows_focus_while_the_warn_glyph_stays_semantic() { + // `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 + // 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`.) + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let app = two_committed_changesets_app(&fixture); // cs-b: current + needs_restack + let theme = Palette::dark(); + let icons = crate::icons::IconMode::None; + + let lit = changeset_prefix_spans(&app, &theme, icons, true); + let dim = changeset_prefix_spans(&app, &theme, icons, false); + + let text_style = |spans: &[TSpan<'static>]| { + spans + .iter() + .find(|s| s.content.contains("[2/2]")) + .expect("counter+title span present") + .style + }; + assert_eq!( + text_style(&lit), + pane_header_label_style(&theme, true), + "the changeset-prefix text lights with focus" + ); + assert_eq!( + text_style(&dim), + pane_header_label_style(&theme, false), + "the changeset-prefix text dims without focus" + ); + + let warn_style = |spans: &[TSpan<'static>]| { + spans + .iter() + .find(|s| s.content.contains('⚠')) + .expect("warn glyph span present") + .style + }; + assert_eq!( + warn_style(&lit).fg, + Some(theme.warn_fg), + "the warn glyph keeps its semantic color while the prefix text is focused" + ); + assert_eq!( + warn_style(&lit), + warn_style(&dim), + "the warn glyph's style is unaffected by the prefix text's focus" + ); + } } diff --git a/git-workon-review/src/synthesis.rs b/git-workon-review/src/synthesis.rs index a0f8934e..936bdc53 100644 --- a/git-workon-review/src/synthesis.rs +++ b/git-workon-review/src/synthesis.rs @@ -145,19 +145,30 @@ pub struct PatchText { } impl PatchText { - /// Render the full patch: a `diff --git`/`index`/`---`/`+++` file header, then each - /// hunk's bytes. Always ends in `\n` (each hunk's last line is either a real line with its - /// own trailing `\n`, or a `missing_newline` line whose marker supplies one). + /// Render the full patch: a `diff --git`/(`new file mode`|`deleted file mode`)?/`index`/ + /// `---`/`+++` file header, then each hunk's bytes. Always ends in `\n` (each hunk's last + /// line is either a real line with its own trailing `\n`, or a `missing_newline` line whose + /// marker supplies one). /// - /// The `index 0000000..0000000 ` line's OIDs are a placeholder — this crate never + /// A one-sided patch (`old_path` or `new_path` is `None` — a whole-file creation or + /// deletion) additionally needs a `new file mode {mode:06o}` / `deleted file mode + /// {mode:06o}` line: without it, git parses the patch as a MODIFICATION of an existing + /// path and rejects it against an untracked/absent preimage ("does not exist in index") — + /// this is the exact shape `git diff --no-index /dev/null ` emits, and the one both + /// `git2::Diff::from_buffer` + `Repository::apply(ApplyLocation::Index)` and `git apply + /// --cached` accept (see the go/no-go test in `tests/suite/file_ops.rs`). The `index` line + /// that follows omits its mode suffix for a one-sided patch — canonical git output has no + /// mode there, since the mode line above already carries it. + /// + /// The `index 0000000..0000000[ ]` line's OIDs are a placeholder — this crate never /// reads blob OIDs off the model (untracked deltas don't have them either), and `git /// apply` ignores them. The line exists because `git2::Diff::from_buffer` parses stricter - /// than `git apply` and rejects a bare 3-line header (plan risk #4). The MODE, however, is - /// load-bearing: `Repository::apply(ApplyLocation::Index, ..)` takes the new index entry's - /// mode straight from this line, so it must be the file's real mode - /// ([`Self::new_mode`]) — a hardcoded `100644` here used to silently clobber the exec bit - /// of any staged `100755` file (the `git apply` CLI path never had this bug: it reads the - /// mode from the working tree instead). + /// than `git apply` and rejects a bare 3-line header (plan risk #4). For a two-sided + /// (Modified/Renamed/Copied) patch, the MODE is load-bearing: `Repository::apply + /// (ApplyLocation::Index, ..)` takes the new index entry's mode straight from this line, so + /// it must be the file's real mode ([`Self::new_mode`]) — a hardcoded `100644` here used to + /// silently clobber the exec bit of any staged `100755` file (the `git apply` CLI path + /// never had this bug: it reads the mode from the working tree instead). pub fn to_bytes(&self) -> Vec { let mut out = Vec::new(); let diff_git_old = self @@ -171,7 +182,23 @@ impl PatchText { .or(self.old_path.as_deref()) .unwrap_or(""); out.extend_from_slice(format!("diff --git a/{diff_git_old} b/{diff_git_new}\n").as_bytes()); - out.extend_from_slice(format!("index 0000000..0000000 {:06o}\n", self.new_mode).as_bytes()); + match (&self.old_path, &self.new_path) { + (None, Some(_)) => { + out.extend_from_slice(format!("new file mode {:06o}\n", self.new_mode).as_bytes()); + out.extend_from_slice(b"index 0000000..0000000\n"); + } + (Some(_), None) => { + out.extend_from_slice( + format!("deleted file mode {:06o}\n", self.old_mode).as_bytes(), + ); + out.extend_from_slice(b"index 0000000..0000000\n"); + } + _ => { + out.extend_from_slice( + format!("index 0000000..0000000 {:06o}\n", self.new_mode).as_bytes(), + ); + } + } let old_label = match &self.old_path { Some(p) => format!("a/{p}"), None => "/dev/null".to_string(), @@ -209,15 +236,23 @@ impl PatchText { /// /// Refuses: /// - binary files ([`SynthesisError::BinaryFile`]) — no hunks exist to synthesize from. -/// - statuses a hunk patch can't express ([`SynthesisError::LineSelectionUnsupported`]): -/// `Added`/`Deleted`/`Untracked`/`Unmerged` are whole-file operations by nature — a hunk -/// patch of a deletion would stage an empty blob instead of removing the file, and a hunk -/// patch of an untracked file has no index/HEAD preimage to apply against (trap 3). CS4's -/// `ops.rs` routes these statuses to `file_ops.rs` before synthesis is ever reached, so +/// - 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 /// `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`). /// - `hunk_idx` out of range ([`SynthesisError::HunkOutOfRange`]). +/// +/// Admits (does NOT refuse): `Modified`/`Renamed`/`Copied` (the original two-sided callers), and +/// — per the line-ops-on-one-sided-files handoff — non-binary `Untracked`/`Added`: these have no +/// `HEAD`/index preimage, but [`partial_hunk_patch`] synthesizes a one-sided (creation) patch for +/// them instead of a two-sided one (see that function's doc). [`whole_hunk_patch`] is technically +/// reachable on these statuses too (it shares this guard), but nothing calls it that way — +/// `ops::apply_hunk`'s routing (`is_hunk_patchable`) is deliberately UNCHANGED and still falls +/// back to the whole-file op for `Untracked`/`Added`, so `whole_hunk_patch` never actually +/// synthesizes a one-sided patch in practice. fn selectable_hunk( file: &FileChange, hunk_idx: usize, @@ -228,7 +263,11 @@ fn selectable_hunk( }); } match file.status { - FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied => {} + FileStatus::Modified + | FileStatus::Renamed + | FileStatus::Copied + | FileStatus::Untracked + | FileStatus::Added => {} other => { return Err(SynthesisError::LineSelectionUnsupported { path: file.path.clone(), @@ -456,12 +495,40 @@ pub struct LineSelection { /// Counts are recomputed per emitted line (context, converted-to-context, kept-add, kept-del /// all bump the relevant side(s)); the header is rebuilt as /// `@@ -old_start,old_count +new_start,new_count @@` plus the source hunk's header suffix -/// (reused via [`header_suffix`]) — the starts are unchanged, only the counts move. +/// (reused via [`header_suffix`]) — `new_start` is unchanged, only the counts move (and, for a +/// one-sided source, `old_start` — see below). /// /// Same refusals as [`whole_hunk_patch`]: binary files ([`SynthesisError::BinaryFile`]), /// unsupported statuses ([`SynthesisError::LineSelectionUnsupported`]), and an out-of-range /// `hunk_idx` ([`SynthesisError::HunkOutOfRange`]). /// +/// ## One-sided sources (`Untracked`/`Added`, no `HEAD`/index preimage) +/// +/// `hunk.lines` is ALL [`LineKind::Addition`] for these statuses (nothing pre-existed, so +/// there's nothing to have context or a deletion among) — the direction rules above still apply +/// mechanically (a dropped addition is omitted under `base == Old`, converted to context under +/// `base == New`), but the RENDERED patch's shape depends on whether any Context line ended up +/// emitted: +/// +/// - `base == Old` (staging a subset of an untracked file's lines): dropped additions are always +/// OMITTED, never converted to context (there's no deletion rule to mirror them into) — so no +/// Context line is ever emitted here, and the rendered patch is always a pure creation: +/// `old_path: None`. +/// - `base == New` (unstaging an Added file's lines, or discarding an Untracked file's lines): +/// dropped additions convert to Context — the lines NOT selected for the reverse-apply must +/// stay in the target. If any survive as Context, the patch has a real (non-empty) old side — +/// `old_path: Some(path)`, so `invert()` renders it as a two-sided modification, not a +/// deletion. If EVERY addition was kept (no Context survives — a full-file selection), +/// `old_path` stays `None`: `invert()` then renders a deletion, matching "removing the whole +/// file" — though [`crate::app`]'s discard flow routes a full untracked selection to the +/// whole-file confirm (fork 2 of the handoff) rather than relying on this implicitly. +/// +/// Either way, `old_start` in the rendered header is `0` when the final `old_count` is `0` (pure +/// creation/no old side), else `1` (a real, if partial, old-side region starting at the file's +/// first line) — `hunk.old_start` itself is `0` for these statuses (git2 has no old-side line +/// numbers to report), so it can't be reused verbatim once the old side gains content the way a +/// two-sided source's `old_start` can. +/// /// A deletion line carrying [`crate::model::HunkLine::missing_newline`] — whether a dropped /// 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 @@ -570,20 +637,43 @@ pub fn partial_hunk_patch( .filter(|l| matches!(l.kind, LineKind::Context | LineKind::Addition)) .count() as u32; + // One-sided sources (no HEAD/index preimage) get a possibly-`None` old_path and a + // recomputed old_start, per this function's doc comment; two-sided sources keep their + // existing (always non-`None`, always-`hunk.old_start`) behavior unchanged. + let one_sided_source = matches!(file.status, FileStatus::Untracked | FileStatus::Added); + let old_path = if one_sided_source { + if old_count == 0 { + None + } else { + Some(old_path) + } + } else { + Some(old_path) + }; + let old_start = if one_sided_source { + if old_count == 0 { + 0 + } else { + 1 + } + } else { + hunk.old_start + }; + let mut header = format!( - "@@ -{},{old_count} +{},{new_count} @@", - hunk.old_start, hunk.new_start + "@@ -{old_start},{old_count} +{},{new_count} @@", + hunk.new_start ) .into_bytes(); header.extend_from_slice(&header_suffix(&hunk.header)); Ok(PatchText { - old_path: Some(old_path), + old_path, new_path: Some(new_path), old_mode: file.old_mode, new_mode: file.new_mode, hunks: vec![PatchHunk { - old_start: hunk.old_start, + old_start, old_count, new_start: hunk.new_start, new_count, @@ -760,6 +850,130 @@ mod tests { assert_eq!(inverted.hunks[0].lines[1].content, b"line2"); } + /// A hand-built one-sided (creation) [`PatchText`] — `whole_hunk_patch`/`partial_hunk_patch` + /// don't synthesize these yet (that's step 3, gated on `selectable_hunk`'s status refusal); + /// this constructs `PatchText` directly to pin `to_bytes`'s header-rendering contract on its + /// own, matching the canonical `git diff --no-index /dev/null file` shape from the handoff. + fn creation_patch() -> PatchText { + PatchText { + old_path: None, + new_path: Some("new.txt".to_string()), + old_mode: 0, + new_mode: 0o100644, + hunks: vec![PatchHunk { + old_start: 0, + old_count: 0, + new_start: 1, + new_count: 2, + header: b"@@ -0,0 +1,2 @@\n".to_vec(), + lines: vec![ + PatchLine { + kind: LineKind::Addition, + content: b"hello\n".to_vec(), + missing_newline: false, + }, + PatchLine { + kind: LineKind::Addition, + content: b"world\n".to_vec(), + missing_newline: false, + }, + ], + }], + } + } + + #[test] + fn creation_patch_renders_new_file_mode_and_bare_index_line() { + let patch = creation_patch(); + + let expected = [ + "diff --git a/new.txt b/new.txt\n", + "new file mode 100644\n", + "index 0000000..0000000\n", + "--- /dev/null\n", + "+++ b/new.txt\n", + "@@ -0,0 +1,2 @@\n", + "+hello\n", + "+world\n", + ] + .concat() + .into_bytes(); + + assert_eq!(patch.to_bytes(), expected); + } + + #[test] + fn deletion_patch_renders_deleted_file_mode_and_bare_index_line() { + let patch = PatchText { + old_path: Some("gone.txt".to_string()), + new_path: None, + old_mode: 0o100644, + new_mode: 0, + hunks: vec![PatchHunk { + old_start: 1, + old_count: 2, + new_start: 0, + new_count: 0, + header: b"@@ -1,2 +0,0 @@\n".to_vec(), + lines: vec![ + PatchLine { + kind: LineKind::Deletion, + content: b"hello\n".to_vec(), + missing_newline: false, + }, + PatchLine { + kind: LineKind::Deletion, + content: b"world\n".to_vec(), + missing_newline: false, + }, + ], + }], + }; + + let expected = [ + "diff --git a/gone.txt b/gone.txt\n", + "deleted file mode 100644\n", + "index 0000000..0000000\n", + "--- a/gone.txt\n", + "+++ /dev/null\n", + "@@ -1,2 +0,0 @@\n", + "-hello\n", + "-world\n", + ] + .concat() + .into_bytes(); + + assert_eq!(patch.to_bytes(), expected); + } + + /// Fork 1's `invert` requirement: a reversed creation patch must render as a DELETION + /// (`deleted file mode`), not silently keep the `new file mode` line — `invert` already + /// swaps `old_path`/`new_path`/`old_mode`/`new_mode`, so this is a round-trip contract test + /// on `to_bytes`'s header rendering, not new inversion logic. + #[test] + fn invert_of_creation_renders_as_deletion_header() { + let patch = creation_patch(); + let inverted = patch.invert(); + + assert_eq!(inverted.old_path.as_deref(), Some("new.txt")); + assert!(inverted.new_path.is_none()); + + let rendered = String::from_utf8(inverted.to_bytes()).unwrap(); + assert!( + rendered.contains("deleted file mode 100644\n"), + "expected a deleted file mode line, got: {rendered}" + ); + assert!( + rendered.contains("index 0000000..0000000\n"), + "expected the bare (mode-suffix-free) index line, got: {rendered}" + ); + assert!(!rendered.contains("new file mode")); + + // invert(invert(creation)) == creation (same contract as the existing modification + // round-trip test above). + assert_eq!(inverted.invert(), patch); + } + #[test] fn refuses_binary_file() { let file = FileChange { @@ -788,12 +1002,11 @@ mod tests { #[test] fn refuses_statuses_a_hunk_patch_cannot_express() { - for status in [ - FileStatus::Added, - FileStatus::Deleted, - FileStatus::Untracked, - FileStatus::Unmerged, - ] { + // Deleted/Unmerged stay refused (fork 4): neither a two-sided nor a one-sided hunk + // patch can express them. Added/Untracked are no longer in this list — `selectable_hunk` + // now admits them (see its doc comment) for `partial_hunk_patch`'s one-sided path; see + // the synthesis unit tests around `creation_patch` for their positive coverage. + for status in [FileStatus::Deleted, FileStatus::Unmerged] { let file = FileChange { path: "f.txt".to_string(), old_path: None, @@ -1000,12 +1213,9 @@ mod tests { #[test] fn partial_refuses_statuses_a_hunk_patch_cannot_express() { - for status in [ - FileStatus::Added, - FileStatus::Deleted, - FileStatus::Untracked, - FileStatus::Unmerged, - ] { + // Deleted/Unmerged stay refused (fork 4); Added/Untracked are covered separately below + // (they now synthesize a one-sided patch instead of refusing). + for status in [FileStatus::Deleted, FileStatus::Unmerged] { let file = FileChange { path: "f.txt".to_string(), old_path: None, @@ -1024,4 +1234,123 @@ mod tests { ); } } + + /// A pure-addition hunk shaped like an `Untracked` file's — `old_start`/`old_count` are `0`, + /// every line is an `Addition`, `old_path` is `None` — the fixture for the one-sided + /// `partial_hunk_patch` unit tests below. + fn untracked_hunk() -> Hunk { + let line = |content: &str, new_lnum| HunkLine { + kind: LineKind::Addition, + content: content.as_bytes().to_vec(), + old_lnum: None, + new_lnum: Some(new_lnum), + missing_newline: false, + }; + Hunk { + old_start: 0, + old_count: 0, + new_start: 1, + new_count: 3, + header: b"@@ -0,0 +1,3 @@\n".to_vec(), + lines: vec![line("one\n", 1), line("two\n", 2), line("three\n", 3)], + } + } + + fn untracked_file(hunk: Hunk) -> FileChange { + FileChange { + path: "new.txt".to_string(), + old_path: None, + status: FileStatus::Untracked, + is_binary: false, + old_mode: 0, + new_mode: 0o100644, + hunks: vec![hunk], + } + } + + /// `base == Old` (staging a subset of an untracked file's lines): dropped additions are + /// always OMITTED, never converted to context — the rendered patch is always a pure + /// creation, `old_path: None`, regardless of which lines are kept. + #[test] + fn partial_base_old_on_untracked_renders_a_pure_creation() { + let file = untracked_file(untracked_hunk()); + let sel = LineSelection { + keep_adds: BTreeSet::from([0, 2]), // "one" and "three"; "two" dropped + keep_dels: BTreeSet::new(), + }; + let patch = partial_hunk_patch(&file, 0, &sel, PatchBase::Old).unwrap(); + + assert_eq!(patch.old_path, None); + assert_eq!(patch.new_path.as_deref(), Some("new.txt")); + assert_eq!(patch.hunks[0].old_start, 0); + assert_eq!(patch.hunks[0].old_count, 0); + + let expected = [ + "diff --git a/new.txt b/new.txt\n", + "new file mode 100644\n", + "index 0000000..0000000\n", + "--- /dev/null\n", + "+++ b/new.txt\n", + "@@ -0,0 +1,2 @@\n", + "+one\n", + "+three\n", + ] + .concat() + .into_bytes(); + assert_eq!(patch.to_bytes(), expected); + } + + /// `base == New` (unstaging/discarding a subset of lines) with a PARTIAL selection: the + /// dropped additions survive as Context, so the patch gains a real old side — + /// `old_path: Some(path)`, `old_start: 1` — and `invert()` (the actual apply direction for + /// Unstage/Discard) renders a two-sided MODIFICATION, not a deletion. + #[test] + fn partial_base_new_on_untracked_with_partial_selection_keeps_old_path() { + let file = untracked_file(untracked_hunk()); + let sel = LineSelection { + keep_adds: BTreeSet::from([0]), // only "one" selected for reverse-apply + keep_dels: BTreeSet::new(), + }; + let patch = partial_hunk_patch(&file, 0, &sel, PatchBase::New).unwrap(); + + assert_eq!(patch.old_path.as_deref(), Some("new.txt")); + assert_eq!(patch.new_path.as_deref(), Some("new.txt")); + assert_eq!(patch.hunks[0].old_start, 1); + assert_eq!(patch.hunks[0].old_count, 2); // "two" and "three" survive as context + + let inverted = patch.invert(); + assert_eq!(inverted.old_path.as_deref(), Some("new.txt")); + assert_eq!(inverted.new_path.as_deref(), Some("new.txt")); + let rendered = String::from_utf8(inverted.to_bytes()).unwrap(); + assert!( + !rendered.contains("deleted file mode") && !rendered.contains("new file mode"), + "expected a two-sided modification header, got: {rendered}" + ); + } + + /// `base == New` with a FULL selection (every addition kept): no Context survives, so + /// `old_path` stays `None` and `invert()` renders a deletion — the shape a full-file discard + /// would produce if it went through this path (which `app.rs`'s routing avoids per fork 2, + /// but the synthesis-level contract still holds on its own). + #[test] + fn partial_base_new_on_untracked_with_full_selection_renders_as_deletion_when_inverted() { + let file = untracked_file(untracked_hunk()); + let sel = LineSelection { + keep_adds: BTreeSet::from([0, 1, 2]), + keep_dels: BTreeSet::new(), + }; + let patch = partial_hunk_patch(&file, 0, &sel, PatchBase::New).unwrap(); + + assert_eq!(patch.old_path, None); + assert_eq!(patch.hunks[0].old_start, 0); + assert_eq!(patch.hunks[0].old_count, 0); + + let inverted = patch.invert(); + assert!(inverted.new_path.is_none()); + let rendered = String::from_utf8(inverted.to_bytes()).unwrap(); + assert!( + rendered.contains("deleted file mode 100644\n"), + "expected a deletion header, got: {rendered}" + ); + } } diff --git a/git-workon-review/src/terminal_query.rs b/git-workon-review/src/terminal_query.rs index b4f9cc4e..f3ad7923 100644 --- a/git-workon-review/src/terminal_query.rs +++ b/git-workon-review/src/terminal_query.rs @@ -5,9 +5,10 @@ //! does this by querying the terminal over the controlling `/dev/tty` with OSC escape sequences //! (`OSC 4;n;?` for the 16 ANSI colors, `OSC 11;?`/`OSC 10;?` for background/foreground), parsing //! the RGB replies, and mapping ANSI-16 → the 16 base16 slots ([`crate::theme::Base16`]). The six -//! slots ANSI lacks are synthesized by interpolation (see [`build_base16`]). The diff/cursor -//! *tints* are NOT derived from the probe — [`crate::theme::Palette::from_terminal`] keeps them -//! curated by background luminance (the CS6 refinement of ADR-035). +//! slots ANSI lacks are synthesized by interpolation (see [`build_base16`]). From the probed +//! scheme, [`crate::theme::Palette::from_terminal`] also derives the diff washes (probed ANSI +//! red/green blended toward the probed background — the ADR-035 derived-washes addendum); only +//! the cursor/selection washes stay curated by background luminance. //! //! ## Robustness is the whole point //! @@ -789,8 +790,11 @@ mod tests { ); let keyword = crate::highlight::capture_index("keyword").unwrap(); assert_eq!(palette.syntax(keyword), expected.slots[14]); - // A dark probed bg borrows dark's curated tints. - assert_eq!(palette.del_subtle, Palette::dark().del_subtle); + // The diff washes derive from the PROBED accents (see theme.rs's from_terminal tests for + // the arithmetic) — a complete probe must not produce the curated fallback's washes. + assert_ne!(palette.del_line_bg, Palette::dark().del_line_bg); + // A dark probed bg still borrows dark's curated cursor wash. + assert_eq!(palette.cursor_bg, Palette::dark().cursor_bg); } #[test] @@ -802,8 +806,8 @@ mod tests { foreground: None, }; assert_eq!( - palette_for_auto(&light_bg).del_subtle, - Palette::light().del_subtle + palette_for_auto(&light_bg).del_line_bg, + Palette::light().del_line_bg ); // Background answered dark → curated dark. @@ -813,8 +817,8 @@ mod tests { foreground: None, }; assert_eq!( - palette_for_auto(&dark_bg).del_subtle, - Palette::dark().del_subtle + palette_for_auto(&dark_bg).del_line_bg, + Palette::dark().del_line_bg ); } @@ -822,8 +826,8 @@ mod tests { fn palette_for_auto_falls_back_to_dark_when_nothing_answered() { // The total-failure / timeout path: an empty result → curated dark, never a hang. assert_eq!( - palette_for_auto(&ProbeResult::default()).del_subtle, - Palette::dark().del_subtle + palette_for_auto(&ProbeResult::default()).del_line_bg, + Palette::dark().del_line_bg ); } } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index f5a27c8e..09735fe7 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -25,7 +25,8 @@ //! them too. `dark()` keeps the three shipped RGB values verbatim (the same pixel-identity //! precedent as its diff/cursor tints); `light()` takes `ONE_LIGHT`'s base08/base0A/base0B; //! `from_terminal()` takes the probed scheme's base08/base0A/base0B directly (matching the syntax -//! slots' reasoning, not the curated-tint-borrowing the diff/cursor washes use). +//! 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 — @@ -38,6 +39,20 @@ //! [`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 +//! (`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. +//! +//! **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 +//! 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. use ratatui::style::Color; @@ -105,6 +120,69 @@ impl Base16 { } } +/// Parse a `workon.review.theme.*` color value: `#rrggbb` or bare `rrggbb`, six hex digits, +/// case-insensitive. Deliberately no 3-digit shorthand (`#fff`) — the config schema names only +/// the 6-digit form, so a shorthand is treated the same as any other malformed value: `None`, +/// which `config::ReviewConfig::theme_overrides` turns into an ignore-and-warn. +pub(crate) fn parse_hex_color(s: &str) -> Option { + let hex = s.strip_prefix('#').unwrap_or(s); + if hex.len() != 6 { + return None; + } + let channel = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok(); + Some(Color::Rgb(channel(0)?, channel(2)?, channel(4)?)) +} + +/// 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 +/// [`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 +/// keys just fall through to the unrecognized-key warning now. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ThemeOverrides { + slots: [Option; 16], + pub del_line_bg: Option, + pub del_edit_bg: Option, + pub add_line_bg: Option, + pub add_edit_bg: Option, + pub del_staged_line_bg: Option, + pub del_staged_edit_bg: Option, + pub add_staged_line_bg: Option, + pub add_staged_edit_bg: Option, + pub add_fg: Option, + pub del_fg: Option, + pub add_staged_fg: Option, + pub del_staged_fg: Option, + pub cursor_bg: Option, + pub selection_bg: Option, + pub cursor_unfocused_bg: Option, + pub pane_header_focused_fg: Option, + pub filler_fg: Option, +} + +impl ThemeOverrides { + /// Set the override for base16 slot `index` (0–15, i.e. `base00`–`base0f`). Panics on an + /// out-of-range index — callers (`config::ReviewConfig::theme_overrides`) only reach this + /// after validating the slot name parsed to `0..16`. + pub fn set_slot(&mut self, index: usize, color: Color) { + self.slots[index] = Some(color); + } + + /// Whether no slot or tint override is set — used to skip [`Palette::apply_overrides`] + /// entirely when `workon.review.theme.*` is unset (the common case). Compared against + /// `default()` rather than enumerating fields so a future tint field can't be forgotten + /// here silently. + pub fn is_empty(&self) -> bool { + *self == Self::default() + } +} + /// Blend `color` toward `base` by `ratio` (`0.0` = `color` unchanged, `1.0` = `base`) — linear /// interpolation per RGB channel. This is the "convex blend toward base00" derivation ADR-035 /// describes for a LIGHT base00: blending an accent toward a light background yields a pale, @@ -121,13 +199,96 @@ pub(crate) fn tint_toward(color: Color, base: Color, ratio: f32) -> Color { } } +/// WCAG relative luminance of a single sRGB channel (`0..=255` → `0.0..=1.0`, linearized): the +/// low-end segment is linear, the rest is the sRGB gamma curve inverted. Shared by +/// [`relative_luminance`]. +fn linearize_channel(c: u8) -> f64 { + let cs = c as f64 / 255.0; + if cs <= 0.03928 { + cs / 12.92 + } else { + ((cs + 0.055) / 1.055).powf(2.4) + } +} + +/// 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`]). +fn relative_luminance(color: Color) -> Option { + match color { + Color::Rgb(r, g, b) => Some( + 0.2126 * linearize_channel(r) + + 0.7152 * linearize_channel(g) + + 0.0722 * linearize_channel(b), + ), + _ => None, + } +} + +/// WCAG contrast ratio between two colors (`(L1+0.05)/(L2+0.05)`, lighter over darker) — `None` if +/// either side is non-RGB. +fn contrast_ratio(a: Color, b: Color) -> Option { + let la = relative_luminance(a)?; + let lb = relative_luminance(b)?; + let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; + 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 +/// 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 +/// 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 +/// [`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). +/// 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. +pub(crate) fn staged_foreground(accent: Color, background: Color, edit_bg: Color) -> Color { + let meets_floor = |color: Color| { + contrast_ratio(color, edit_bg) + .map(|ratio| ratio >= STAGED_FG_LUMINANCE_FLOOR) + .unwrap_or(true) + }; + if !meets_floor(accent) { + return accent; + } + let nominal = tint_toward(accent, background, STAGED_FG_DIM_RATIO); + if meets_floor(nominal) { + return nominal; + } + // Binary search for the largest ratio in (0, STAGED_FG_DIM_RATIO) that still clears the + // floor — `lo` starts at the undimmed accent (known to clear it, checked above), `hi` at the + // nominal dim (known to fail it). + let mut lo = 0.0_f32; + let mut hi = STAGED_FG_DIM_RATIO; + for _ in 0..20 { + let mid = (lo + hi) / 2.0; + if meets_floor(tint_toward(accent, background, mid)) { + lo = mid; + } else { + hi = mid; + } + } + tint_toward(accent, background, lo) +} + /// 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 /// [`Palette::light`]'s derived washes. A non-RGB color (never produced by the OSC probe) reads as -/// dark. -pub(crate) fn is_light_background(color: Color) -> bool { +/// dark. `pub` (not `pub(crate)`) since CS2 (`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 { Color::Rgb(r, g, b) => r as u32 + g as u32 + b as u32 > 382, _ => false, @@ -178,38 +339,88 @@ pub fn syntax_slot_count() -> usize { SYNTAX_SLOTS.len() } +/// Per-capture italics template, parallel to [`SYNTAX_SLOTS`] (the array length ties the two at +/// compile time): `true` for the captures rendered in italics under every scheme. Only comments +/// today — the near-universal editor convention (and the prototype's look) — carried as a +/// structural style like `render`'s BOLD chrome rather than a palette field: it isn't a color, so +/// it survives [`Palette::mono`]/`NO_COLOR` (where it becomes the only remaining comment marker) +/// and needs no per-theme value. +const SYNTAX_ITALICS: [bool; SYNTAX_SLOTS.len()] = { + let mut italics = [false; SYNTAX_SLOTS.len()]; + italics[1] = true; // comment (index in `crate::highlight::HIGHLIGHT_NAMES`) + italics +}; + +/// Whether a capture index renders in italics (see [`SYNTAX_ITALICS`]). Panics on an +/// out-of-range index, exactly as [`Palette::syntax`] does — the index always comes from the +/// bound capture space. +pub fn syntax_italic(capture: usize) -> bool { + SYNTAX_ITALICS[capture] +} + /// The resolved on-tint palette a frame is painted with (ADR-035's theme-controlled half). /// /// 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). +#[derive(Clone)] pub struct Palette { /// Per-capture syntax fg, indexed by the same capture index as /// [`crate::highlight::HIGHLIGHT_NAMES`] (see [`SYNTAX_SLOTS`]). syntax: Vec, - /// Whole-line subtle / word-level strong background for an unstaged (bright) Del cell. - pub del_subtle: Color, - pub del_strong: Color, - /// Bright Add-cell background pair (counterpart of [`Palette::del_subtle`]). - pub add_subtle: Color, - pub add_strong: Color, + /// 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). + 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 /// reads as "already handled" without disappearing into plain context. - pub del_staged_subtle: Color, - pub del_staged_strong: Color, + pub del_staged_line_bg: Color, + pub del_staged_edit_bg: Color, /// Dim Add pair — green-tinted counterpart of the staged Del pair. - pub add_staged_subtle: Color, - pub add_staged_strong: Color, + 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`]/ + /// [`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 + /// [`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. + 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 + /// constructor for the derivation). + pub add_staged_fg: Color, + /// Staged counterpart of [`Palette::del_fg`] — dimmed toward [`Palette::background`], + /// contrast-clamped against [`Palette::del_staged_edit_bg`]. + pub del_staged_fg: Color, /// Tint blended into the cursor row's background — a cool slate-blue. pub cursor_bg: Color, /// Tint blended into a selected (line-selection) row — a muted teal, distinct from /// [`Palette::cursor_bg`]. pub selection_bg: Color, - /// Cursor wash for the outline pane while OPEN but NOT focused — dimmer than [`Palette::cursor_bg`]. - pub outline_cursor_unfocused_bg: Color, + /// Cursor wash for ANY pane's remembered cursor row while that pane does NOT hold focus — + /// dimmer than [`Palette::cursor_bg`]. Originally the outline-only field + /// `outline_cursor_unfocused_bg`; renamed (`unfocused-cursor-wash`) when the diff body and + /// 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 + /// `Color::Reset`) BOLD alone still marks the focused label. + pub pane_header_focused_fg: Color, /// The screen/canvas background (base00) — painted by [`crate::render::render`] when /// [`Palette::paint_canvas`] is set, so a curated theme's background actually shows instead of @@ -222,6 +433,14 @@ pub struct Palette { pub dim: Color, /// Gutter/divider foreground (base04) — line-number gutters and pane dividers. pub gutter: Color, + /// Foreground for the deleted-gap filler hatch (`render`'s `Row::Filler` `╱` runs) — base01, + /// the ramp slot nearest the background: the hatch is pure texture ("nothing on this side of + /// the split"), not text, so it recedes behind even dim labels. Previously painted with + /// [`Palette::dim`], which tied it to the comment tone (base03) and dragged the hatch + /// brighter whenever comments were retuned; base02 (the next step up) still read too bright + /// 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, /// 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 @@ -253,6 +472,31 @@ 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 + /// 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 + /// [`Palette::foreground`] when it's set, rather than trusting the icon's own color. + pub colorless: bool, +} + +/// The startup context [`crate::config::resolve_runtime`] needs to resolve a palette but can't +/// derive itself, because it's pure/I/O-free and doesn't own the tty. Built once in `main.rs` +/// (after the `theme = auto` probe, if one ran) and reused by every later `resolve_runtime` call — +/// including a config reload — so `auto` is never re-probed mid-session (re-probing needs the tty, +/// which the TUI owns once the alternate screen is live; a second conversation there would corrupt +/// input). +pub struct PaletteContext { + /// Base palette to use when `theme = auto`: the startup probe's result, or the startup base for + /// a non-`auto` launch (no probe ever ran, so this is just whatever `Dark`/`Light`/the + /// config-read-error fallback resolved to). Never re-probed — a `theme` reload that switches + /// TO `auto` reuses this cached base rather than asking the terminal again. + pub auto_base: Palette, + /// `NO_COLOR` was set in the environment at launch — mono wins over every override, applied + /// last in [`crate::config::resolve_runtime`]'s ladder. Read once at startup (`main.rs`); a + /// reload can't change it, since it isn't a config value. + pub no_color: bool, } impl Palette { @@ -266,23 +510,36 @@ impl Palette { /// shipped values verbatim. pub fn dark() -> Self { let base = Base16::EIGHTIES_DARK; + // Bound rather than repeated inline below: the staged foregrounds measure their contrast + // floor against these exact washes, so a future retune must not be able to move the wash + // while leaving the clamp reading a stale literal. + let del_staged_edit_bg = Color::Rgb(64, 38, 40); + let add_staged_edit_bg = Color::Rgb(34, 50, 38); Palette { syntax: SYNTAX_SLOTS.iter().map(|&s| base.slot(s)).collect(), - del_subtle: Color::Rgb(60, 24, 24), - del_strong: Color::Rgb(120, 40, 40), - add_subtle: Color::Rgb(20, 48, 24), - add_strong: Color::Rgb(32, 100, 48), - del_staged_subtle: Color::Rgb(42, 26, 28), - del_staged_strong: Color::Rgb(64, 38, 40), - add_staged_subtle: Color::Rgb(24, 34, 26), - add_staged_strong: Color::Rgb(34, 50, 38), + del_line_bg: Color::Rgb(60, 24, 24), + del_edit_bg: Color::Rgb(120, 40, 40), + add_line_bg: Color::Rgb(20, 48, 24), + add_edit_bg: Color::Rgb(32, 100, 48), + del_staged_line_bg: Color::Rgb(42, 26, 28), + 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. + 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), + del_staged_fg: staged_foreground(base.slot(8), base.slot(0), del_staged_edit_bg), cursor_bg: Color::Rgb(45, 50, 90), selection_bg: Color::Rgb(30, 66, 66), - outline_cursor_unfocused_bg: Color::Rgb(35, 38, 55), + cursor_unfocused_bg: Color::Rgb(35, 38, 55), + pane_header_focused_fg: base.slot(5), background: base.slot(0), foreground: base.slot(5), dim: base.slot(3), gutter: base.slot(4), + filler_fg: base.slot(1), // 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). error_fg: Color::Rgb(220, 60, 60), @@ -295,6 +552,7 @@ impl Palette { // directly rather than an authored literal. modified_fg: base.slot(9), paint_canvas: true, + colorless: false, } } @@ -303,10 +561,10 @@ impl Palette { /// blending an accent toward a *light* base00 gives the correct pale wash (unlike dark, which /// must hold its tints explicit; see [`Palette::dark`]'s doc comment). /// - /// Ratios were hand-tuned against four requirements: subtle vs strong must read as visibly - /// distinct steps, add vs green must be distinguishable at a glance, staged must read dimmer - /// (more washed-out) than unstaged, and every wash must stay legible under the scheme's dark - /// base05 foreground and accent text. The del/add pair (base08/base0B → base00) derived + /// Ratios were hand-tuned against four requirements: the line and edit washes must read as + /// visibly distinct steps, add vs green must be distinguishable at a glance, staged must read + /// dimmer (more washed-out) than unstaged, and every wash must stay legible under the scheme's + /// dark base05 foreground and accent text. The del/add pair (base08/base0B → base00) derived /// cleanly at those ratios; cursor/selection reuse the same mechanism against base0D/base0C /// (blue/cyan) for a cool wash appropriate on a light background. pub fn light() -> Self { @@ -317,75 +575,117 @@ impl Palette { let blue = base.slot(13); // base0D let cyan = base.slot(12); // base0C - // Unstaged: a light wash (subtle) and a more saturated wash (strong) a reader's eye can + // Unstaged: a light whole-line wash and a more saturated edit wash a reader's eye can // pick out at a glance; staged pushes further toward base00 (less saturated → dimmer). - const SUBTLE: f32 = 0.88; - const STRONG: f32 = 0.65; - const STAGED_SUBTLE: f32 = 0.94; - const STAGED_STRONG: f32 = 0.80; + const LINE: f32 = 0.88; + const EDIT: f32 = 0.65; + const STAGED_LINE: f32 = 0.94; + const STAGED_EDIT: f32 = 0.80; const CURSOR: f32 = 0.82; - const OUTLINE_CURSOR_UNFOCUSED: f32 = 0.90; + const CURSOR_UNFOCUSED: f32 = 0.90; + + let del_staged_edit_bg = tint_toward(red, base00, STAGED_EDIT); + let add_staged_edit_bg = tint_toward(green, base00, STAGED_EDIT); Palette { syntax: SYNTAX_SLOTS.iter().map(|&s| base.slot(s)).collect(), - del_subtle: tint_toward(red, base00, SUBTLE), - del_strong: tint_toward(red, base00, STRONG), - add_subtle: tint_toward(green, base00, SUBTLE), - add_strong: tint_toward(green, base00, STRONG), - del_staged_subtle: tint_toward(red, base00, STAGED_SUBTLE), - del_staged_strong: tint_toward(red, base00, STAGED_STRONG), - add_staged_subtle: tint_toward(green, base00, STAGED_SUBTLE), - add_staged_strong: tint_toward(green, base00, STAGED_STRONG), + del_line_bg: tint_toward(red, base00, LINE), + del_edit_bg: tint_toward(red, base00, EDIT), + add_line_bg: tint_toward(green, base00, LINE), + add_edit_bg: tint_toward(green, base00, EDIT), + del_staged_line_bg: tint_toward(red, base00, STAGED_LINE), + del_staged_edit_bg, + add_staged_line_bg: tint_toward(green, base00, STAGED_LINE), + add_staged_edit_bg, + add_fg: green, + del_fg: red, + add_staged_fg: staged_foreground(green, base00, add_staged_edit_bg), + del_staged_fg: staged_foreground(red, base00, del_staged_edit_bg), cursor_bg: tint_toward(blue, base00, CURSOR), selection_bg: tint_toward(cyan, base00, CURSOR), - outline_cursor_unfocused_bg: tint_toward(blue, base00, OUTLINE_CURSOR_UNFOCUSED), + cursor_unfocused_bg: tint_toward(blue, base00, CURSOR_UNFOCUSED), + pane_header_focused_fg: base.slot(5), background: base.slot(0), foreground: base.slot(5), dim: base.slot(3), gutter: base.slot(4), + filler_fg: base.slot(1), error_fg: red, warn_fg: base.slot(10), // base0A current_fg: green, heading_fg: cyan, modified_fg: base.slot(9), // base09 paint_canvas: true, + colorless: false, } } /// A scheme derived from the terminal's own colors (ADR-035's `auto`, CS6). 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/cursor tints, - /// however, stay **curated by background luminance** rather than derived from the probed - /// accents — the CS6 refinement of ADR-035: dark-tint derivation is unsolved (see - /// [`Palette::dark`]) and deriving washes from an arbitrary terminal's accent is - /// unpredictable, whereas the value of terminal-derivation — code colors matching the - /// terminal — is fully delivered by the probed syntax slots. A probed dark background borrows - /// [`Palette::dark`]'s tints, a light one [`Palette::light`]'s. + /// 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 + /// 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"). + /// + /// 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 + /// cursor row on an aqua-leaning theme), so per-theme judgment there belongs to the override + /// tier, not derivation. pub fn from_terminal(base: Base16) -> Self { - let curated = if is_light_background(base.slot(0)) { + let background = base.slot(0); + let light = is_light_background(background); + let curated = if light { Palette::light() } else { Palette::dark() }; + let red = base.slot(8); // base08 — probed ANSI red + let green = base.slot(11); // base0B — probed ANSI green + // (line, edit, staged_line, staged_edit): how far each wash blends from the accent + // toward the probed background. Light reuses `Palette::light`'s tuned ratios. + let (line, edit, staged_line, staged_edit) = if light { + (0.88, 0.65, 0.94, 0.80) + } else { + (0.90, 0.75, 0.94, 0.85) + }; + let del_staged_edit_bg = tint_toward(red, background, staged_edit); + let add_staged_edit_bg = tint_toward(green, background, staged_edit); + Palette { syntax: SYNTAX_SLOTS.iter().map(|&s| base.slot(s)).collect(), - del_subtle: curated.del_subtle, - del_strong: curated.del_strong, - add_subtle: curated.add_subtle, - add_strong: curated.add_strong, - del_staged_subtle: curated.del_staged_subtle, - del_staged_strong: curated.del_staged_strong, - add_staged_subtle: curated.add_staged_subtle, - add_staged_strong: curated.add_staged_strong, + del_line_bg: tint_toward(red, background, line), + del_edit_bg: tint_toward(red, background, edit), + add_line_bg: tint_toward(green, background, line), + add_edit_bg: tint_toward(green, background, edit), + del_staged_line_bg: tint_toward(red, background, staged_line), + del_staged_edit_bg, + add_staged_line_bg: tint_toward(green, background, staged_line), + add_staged_edit_bg, + // Foreground defaults role-map to the accents, same as `dark`/`light` — probed base08 + // for del, probed base0B for add (matching the syntax/chrome fields just below: `auto` + // takes these straight from the terminal, not the curated fallback). + add_fg: green, + del_fg: red, + add_staged_fg: staged_foreground(green, background, add_staged_edit_bg), + del_staged_fg: staged_foreground(red, background, del_staged_edit_bg), cursor_bg: curated.cursor_bg, selection_bg: curated.selection_bg, - outline_cursor_unfocused_bg: curated.outline_cursor_unfocused_bg, + cursor_unfocused_bg: curated.cursor_unfocused_bg, // Derived straight from the probed terminal scheme (NOT the curated fallback) — this // is the whole point of `auto`: chrome that matches the terminal's own colors. background: base.slot(0), foreground: base.slot(5), + pane_header_focused_fg: base.slot(5), dim: base.slot(3), gutter: base.slot(4), + filler_fg: base.slot(1), // Semantic chrome also matches the terminal — probed base08/base0A/base0B, not the // curated fallback's (mirrors the syntax slots' reasoning just above). error_fg: base.slot(8), @@ -398,6 +698,82 @@ impl Palette { // would defeat terminal transparency/background images for no benefit (the probed // fg/dim/gutter already match the inherited bg, since they came from the same probe). paint_canvas: false, + colorless: false, + } + } + + /// The achromatic scheme used when `NO_COLOR` is set (CS2, NO_COLOR support, `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 + /// 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 + /// picks the right ladder). Hand-tuned to preserve the same three invariants the curated + /// schemes maintain: line vs edit read as distinct steps, staged reads dimmer (closer to + /// the implied background) than unstaged, and cursor vs selection are distinct. Add and Del + /// share one ladder — colorless mode can't carry add-vs-del by hue, so that distinction + /// falls to gutter glyph/structure instead, an accepted, documented degradation (see + /// ADR-035's NO_COLOR note). + pub fn mono(light: bool) -> Self { + // (line, edit, staged_line, staged_edit, cursor, selection, cursor_unfocused) + let (line, edit, staged_line, staged_edit, cursor, selection, cursor_unfocused) = if light { + ( + Color::Rgb(215, 215, 215), + Color::Rgb(165, 165, 165), + Color::Rgb(230, 230, 230), + Color::Rgb(205, 205, 205), + Color::Rgb(190, 190, 190), + Color::Rgb(200, 200, 200), + Color::Rgb(210, 210, 210), + ) + } else { + ( + Color::Rgb(40, 40, 40), + Color::Rgb(90, 90, 90), + Color::Rgb(25, 25, 25), + Color::Rgb(50, 50, 50), + Color::Rgb(65, 65, 65), + Color::Rgb(55, 55, 55), + Color::Rgb(45, 45, 45), + ) + }; + + Palette { + syntax: vec![Color::Reset; SYNTAX_SLOTS.len()], + del_line_bg: line, + del_edit_bg: edit, + add_line_bg: line, + add_edit_bg: edit, + del_staged_line_bg: staged_line, + del_staged_edit_bg: staged_edit, + add_staged_line_bg: staged_line, + add_staged_edit_bg: staged_edit, + // Foreground fields, so they collapse to Reset like every other fg field under + // NO_COLOR — colorless mode carries no per-capture hue at all. + add_fg: Color::Reset, + del_fg: Color::Reset, + add_staged_fg: Color::Reset, + del_staged_fg: Color::Reset, + cursor_bg: cursor, + selection_bg: selection, + cursor_unfocused_bg: cursor_unfocused, + pane_header_focused_fg: Color::Reset, + background: Color::Reset, + foreground: Color::Reset, + dim: Color::Reset, + gutter: Color::Reset, + filler_fg: Color::Reset, + error_fg: Color::Reset, + warn_fg: Color::Reset, + current_fg: Color::Reset, + heading_fg: Color::Reset, + modified_fg: Color::Reset, + paint_canvas: false, + colorless: true, } } @@ -423,6 +799,126 @@ impl Palette { crate::config::Theme::Auto => Self::dark(), // probe lives in main.rs/terminal_query } } + + /// 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. + /// + /// **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 + /// → [`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 + /// [`Palette::syntax`] entry whose [`SYNTAX_SLOTS`] template maps to that slot. This is + /// deliberately uniform rather than "only override fields the base didn't hand-author": the + /// alternative (silently ignoring a slot override for `dark()`'s hand-tuned `error_fg`) is the + /// UX trap — a user who sets `base08` expects red to change, full stop. base06/07/0f stay + /// unmapped — nothing in this TUI is brighter than its foreground, and base0f is base16's + /// legacy grab-bag; they parse (namespace uniformity) and do nothing. + /// + /// Slot overrides do NOT re-derive the diff/cursor tints — that stays the tint override keys' + /// job, applied last and verbatim below (this ordering — slot arms first — is the invariant + /// that lets an explicit `selection-bg` override still beat a `base02` slot override), so a + /// slot override can't silently reshape a hand-tuned wash it wasn't asked to touch. + pub fn apply_overrides(&mut self, overrides: &ThemeOverrides) { + for (capture, &slot) in SYNTAX_SLOTS.iter().enumerate() { + if let Some(color) = overrides.slots[slot] { + self.syntax[capture] = color; + } + } + + if let Some(color) = overrides.slots[0] { + self.background = color; + self.paint_canvas = true; + } + if let Some(color) = overrides.slots[1] { + self.filler_fg = color; + } + if let Some(color) = overrides.slots[2] { + self.selection_bg = color; + } + if let Some(color) = overrides.slots[3] { + self.dim = color; + } + if let Some(color) = overrides.slots[4] { + self.gutter = color; + } + if let Some(color) = overrides.slots[5] { + self.foreground = color; + } + if let Some(color) = overrides.slots[8] { + self.error_fg = color; + } + if let Some(color) = overrides.slots[9] { + self.modified_fg = color; + } + if let Some(color) = overrides.slots[10] { + self.warn_fg = color; + } + if let Some(color) = overrides.slots[11] { + self.current_fg = color; + } + if let Some(color) = overrides.slots[12] { + self.heading_fg = color; + } + + // Tint overrides assign last and verbatim — unaffected by any slot override above. + if let Some(color) = overrides.del_line_bg { + self.del_line_bg = color; + } + if let Some(color) = overrides.del_edit_bg { + self.del_edit_bg = color; + } + if let Some(color) = overrides.add_line_bg { + self.add_line_bg = color; + } + if let Some(color) = overrides.add_edit_bg { + self.add_edit_bg = color; + } + if let Some(color) = overrides.del_staged_line_bg { + self.del_staged_line_bg = color; + } + if let Some(color) = overrides.del_staged_edit_bg { + self.del_staged_edit_bg = color; + } + if let Some(color) = overrides.add_staged_line_bg { + self.add_staged_line_bg = color; + } + if let Some(color) = overrides.add_staged_edit_bg { + self.add_staged_edit_bg = color; + } + if let Some(color) = overrides.add_fg { + self.add_fg = color; + } + if let Some(color) = overrides.del_fg { + self.del_fg = color; + } + if let Some(color) = overrides.add_staged_fg { + self.add_staged_fg = color; + } + if let Some(color) = overrides.del_staged_fg { + self.del_staged_fg = color; + } + if let Some(color) = overrides.cursor_bg { + self.cursor_bg = color; + } + if let Some(color) = overrides.selection_bg { + self.selection_bg = color; + } + if let Some(color) = overrides.cursor_unfocused_bg { + self.cursor_unfocused_bg = color; + } + if let Some(color) = overrides.pane_header_focused_fg { + self.pane_header_focused_fg = color; + } + if let Some(color) = overrides.filler_fg { + self.filler_fg = color; + } + } } #[cfg(test)] @@ -453,17 +949,17 @@ mod tests { // The pixel-identity gate: `Palette::dark` must reproduce M3–M5's 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_subtle, Color::Rgb(60, 24, 24)); - assert_eq!(t.del_strong, Color::Rgb(120, 40, 40)); - assert_eq!(t.add_subtle, Color::Rgb(20, 48, 24)); - assert_eq!(t.add_strong, Color::Rgb(32, 100, 48)); - assert_eq!(t.del_staged_subtle, Color::Rgb(42, 26, 28)); - assert_eq!(t.del_staged_strong, Color::Rgb(64, 38, 40)); - assert_eq!(t.add_staged_subtle, Color::Rgb(24, 34, 26)); - assert_eq!(t.add_staged_strong, Color::Rgb(34, 50, 38)); + assert_eq!(t.del_line_bg, Color::Rgb(60, 24, 24)); + assert_eq!(t.del_edit_bg, Color::Rgb(120, 40, 40)); + assert_eq!(t.add_line_bg, Color::Rgb(20, 48, 24)); + assert_eq!(t.add_edit_bg, Color::Rgb(32, 100, 48)); + assert_eq!(t.del_staged_line_bg, Color::Rgb(42, 26, 28)); + assert_eq!(t.del_staged_edit_bg, Color::Rgb(64, 38, 40)); + assert_eq!(t.add_staged_line_bg, Color::Rgb(24, 34, 26)); + assert_eq!(t.add_staged_edit_bg, Color::Rgb(34, 50, 38)); assert_eq!(t.cursor_bg, Color::Rgb(45, 50, 90)); assert_eq!(t.selection_bg, Color::Rgb(30, 66, 66)); - assert_eq!(t.outline_cursor_unfocused_bg, Color::Rgb(35, 38, 55)); + assert_eq!(t.cursor_unfocused_bg, Color::Rgb(35, 38, 55)); } #[test] @@ -510,6 +1006,18 @@ mod tests { assert!(t.paint_canvas); } + #[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 + // 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`. + for t in [Palette::dark(), Palette::light()] { + assert_eq!(t.pane_header_focused_fg, t.foreground); + assert_ne!(t.pane_header_focused_fg, t.dim); + } + } + #[test] fn light_background_is_high_luminance_and_foreground_is_low_luminance() { // A real light theme: a near-white canvas with dark text on it, and it must paint (an @@ -554,40 +1062,39 @@ mod tests { #[test] fn light_del_and_add_tints_are_distinct_from_each_other() { let t = Palette::light(); - assert_ne!(t.del_subtle, t.add_subtle); - assert_ne!(t.del_strong, t.add_strong); - assert_ne!(t.del_staged_subtle, t.add_staged_subtle); - assert_ne!(t.del_staged_strong, t.add_staged_strong); + assert_ne!(t.del_line_bg, t.add_line_bg); + assert_ne!(t.del_edit_bg, t.add_edit_bg); + assert_ne!(t.del_staged_line_bg, t.add_staged_line_bg); + assert_ne!(t.del_staged_edit_bg, t.add_staged_edit_bg); } #[test] - fn light_subtle_and_strong_are_visibly_distinct_steps() { + fn light_line_and_edit_are_visibly_distinct_steps() { let t = Palette::light(); - assert_ne!(t.del_subtle, t.del_strong); - assert_ne!(t.add_subtle, t.add_strong); - // Strong sits further from base00 (more saturated / less washed-out) than subtle. - assert!(distance_from_base00(t.del_strong) > distance_from_base00(t.del_subtle)); - assert!(distance_from_base00(t.add_strong) > distance_from_base00(t.add_subtle)); + assert_ne!(t.del_line_bg, t.del_edit_bg); + assert_ne!(t.add_line_bg, t.add_edit_bg); + // The edit wash sits further from base00 (more saturated / less washed-out) than the + // whole-line wash. + assert!(distance_from_base00(t.del_edit_bg) > distance_from_base00(t.del_line_bg)); + assert!(distance_from_base00(t.add_edit_bg) > distance_from_base00(t.add_line_bg)); } #[test] fn light_staged_reads_dimmer_than_unstaged() { // "Dimmer" == more washed toward base00 == closer to base00 than the unstaged pair. let t = Palette::light(); - assert!(distance_from_base00(t.del_staged_subtle) < distance_from_base00(t.del_subtle)); - assert!(distance_from_base00(t.del_staged_strong) < distance_from_base00(t.del_strong)); - assert!(distance_from_base00(t.add_staged_subtle) < distance_from_base00(t.add_subtle)); - assert!(distance_from_base00(t.add_staged_strong) < distance_from_base00(t.add_strong)); + assert!(distance_from_base00(t.del_staged_line_bg) < distance_from_base00(t.del_line_bg)); + assert!(distance_from_base00(t.del_staged_edit_bg) < distance_from_base00(t.del_edit_bg)); + assert!(distance_from_base00(t.add_staged_line_bg) < distance_from_base00(t.add_line_bg)); + assert!(distance_from_base00(t.add_staged_edit_bg) < distance_from_base00(t.add_edit_bg)); } #[test] - fn light_cursor_and_selection_washes_are_distinct_and_outline_cursor_is_dimmer() { + fn light_cursor_and_selection_washes_are_distinct_and_unfocused_cursor_is_dimmer() { let t = Palette::light(); assert_ne!(t.cursor_bg, t.selection_bg); - // The unfocused outline cursor wash should read dimmer than the focused cursor wash. - assert!( - distance_from_base00(t.outline_cursor_unfocused_bg) < distance_from_base00(t.cursor_bg) - ); + // The unfocused cursor wash should read dimmer than the focused cursor wash. + assert!(distance_from_base00(t.cursor_unfocused_bg) < distance_from_base00(t.cursor_bg)); } #[test] @@ -639,6 +1146,38 @@ mod tests { Base16 { slots } } + #[test] + fn filler_fg_is_base01_the_ramp_slot_nearest_the_background() { + // The deleted-gap hatch recedes behind dim text: base01 (the bg-nearest ramp slot), + // decoupled from base03 so a retuned comment/dim tone no longer drags the hatch with it. + // Holds in every scheme, including `auto`'s probed ramp. + let lum = |c: Color| match c { + Color::Rgb(r, g, b) => r as u32 + g as u32 + b as u32, + other => panic!("expected RGB, got {other:?}"), + }; + for palette in [ + Palette::dark(), + Palette::from_terminal(probed_base16(Color::Rgb(0x1a, 0x1a, 0x1a))), + ] { + assert!( + lum(palette.filler_fg) < lum(palette.dim), + "dark-scheme filler hatch must sit closer to the background than dim" + ); + } + assert_eq!(Palette::dark().filler_fg, Base16::EIGHTIES_DARK.slot(1)); + assert_eq!(Palette::light().filler_fg, Base16::ONE_LIGHT.slot(1)); + } + + #[test] + fn only_the_comment_capture_renders_italic() { + // Guards SYNTAX_ITALICS's hardcoded index against HIGHLIGHT_NAMES reordering: the italic + // entry must be the one "comment" resolves to, and representative neighbors stay upright. + assert!(syntax_italic(capture_index("comment").unwrap())); + assert!(!syntax_italic(capture_index("keyword").unwrap())); + assert!(!syntax_italic(capture_index("string").unwrap())); + assert!(!syntax_italic(capture_index("attribute").unwrap())); + } + #[test] fn from_terminal_takes_syntax_from_the_probed_scheme() { let probed = probed_base16(Color::Rgb(0x1a, 0x1a, 0x1a)); // dark bg @@ -659,29 +1198,69 @@ mod tests { } #[test] - fn from_terminal_with_a_dark_background_borrows_darks_curated_tints() { + fn from_terminal_derives_diff_washes_from_the_probed_accents() { + // The ADR-035 derived-washes addendum: del/add washes blend the PROBED base08/base0B + // toward the PROBED background — theme-author arithmetic (`accent:mix(bg, 90)`), not the + // curated fallback's generic red/green. + let bg = Color::Rgb(0x1a, 0x1a, 0x1a); // dark + let probed = probed_base16(bg); + let palette = Palette::from_terminal(probed); + let red = probed.slot(8); + let green = probed.slot(11); + assert_eq!(palette.del_line_bg, tint_toward(red, bg, 0.90)); + assert_eq!(palette.del_edit_bg, tint_toward(red, bg, 0.75)); + assert_eq!(palette.add_line_bg, tint_toward(green, bg, 0.90)); + assert_eq!(palette.add_edit_bg, tint_toward(green, bg, 0.75)); + assert_ne!(palette.del_line_bg, Palette::dark().del_line_bg); + } + + #[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). + let bg = Color::Rgb(0x1a, 0x1a, 0x1a); + let probed = probed_base16(bg); + let palette = Palette::from_terminal(probed); + let red = probed.slot(8); + let green = probed.slot(11); + assert_eq!(palette.del_staged_line_bg, tint_toward(red, bg, 0.94)); + assert_eq!(palette.del_staged_edit_bg, tint_toward(red, bg, 0.85)); + assert_eq!(palette.add_staged_line_bg, tint_toward(green, bg, 0.94)); + assert_eq!(palette.add_staged_edit_bg, tint_toward(green, bg, 0.85)); + assert_ne!(palette.del_staged_line_bg, palette.del_line_bg); + assert_ne!(palette.add_staged_edit_bg, palette.add_edit_bg); + } + + #[test] + fn from_terminal_with_a_light_background_derives_with_lights_ratios() { + // A probed LIGHT background reuses `Palette::light`'s hand-tuned blend ratios, applied + // to the probed accents. + let bg = Color::Rgb(0xf5, 0xf5, 0xf5); + let probed = probed_base16(bg); + let palette = Palette::from_terminal(probed); + assert_eq!(palette.del_line_bg, tint_toward(probed.slot(8), bg, 0.88)); + assert_eq!(palette.add_edit_bg, tint_toward(probed.slot(11), bg, 0.65)); + } + + #[test] + fn from_terminal_with_a_dark_background_borrows_darks_curated_cursor_washes() { + // Cursor/selection stay curated-by-luminance (no ANSI counterpart to derive from) even + // though the diff washes now derive. let palette = Palette::from_terminal(probed_base16(Color::Rgb(0x1a, 0x1a, 0x1a))); let dark = Palette::dark(); - assert_eq!(palette.del_subtle, dark.del_subtle); - assert_eq!(palette.add_strong, dark.add_strong); assert_eq!(palette.cursor_bg, dark.cursor_bg); assert_eq!(palette.selection_bg, dark.selection_bg); - assert_eq!( - palette.outline_cursor_unfocused_bg, - dark.outline_cursor_unfocused_bg - ); + assert_eq!(palette.cursor_unfocused_bg, dark.cursor_unfocused_bg); } #[test] - fn from_terminal_with_a_light_background_borrows_lights_curated_tints() { + fn from_terminal_with_a_light_background_borrows_lights_curated_cursor_washes() { let palette = Palette::from_terminal(probed_base16(Color::Rgb(0xf5, 0xf5, 0xf5))); let light = Palette::light(); - assert_eq!(palette.del_subtle, light.del_subtle); - assert_eq!(palette.add_strong, light.add_strong); assert_eq!(palette.cursor_bg, light.cursor_bg); assert_eq!(palette.selection_bg, light.selection_bg); // ...and NOT dark's, confirming the luminance branch flipped. - assert_ne!(palette.del_subtle, Palette::dark().del_subtle); + assert_ne!(palette.cursor_bg, Palette::dark().cursor_bg); } #[test] @@ -701,8 +1280,9 @@ mod tests { #[test] fn from_terminal_takes_semantic_fg_from_the_probed_scheme_not_the_curated_fallback() { // Same reasoning as syntax/chrome: `auto`'s error/warn/current colors should match the - // terminal, not borrow the curated dark/light fallback's (unlike the diff/cursor tints, - // which DO borrow — see `from_terminal_with_a_dark_background_borrows_darks_curated_tints`). + // terminal, not borrow the curated dark/light fallback's (unlike the cursor/selection + // washes, which DO borrow — see + // `from_terminal_with_a_dark_background_borrows_darks_curated_cursor_washes`). let probed = probed_base16(Color::Rgb(0x1a, 0x1a, 0x1a)); let palette = Palette::from_terminal(probed); assert_eq!(palette.error_fg, probed.slot(8)); @@ -726,21 +1306,418 @@ mod tests { use crate::config::Theme; assert_eq!( - Palette::for_theme(Theme::Light).del_subtle, - Palette::light().del_subtle + Palette::for_theme(Theme::Light).del_line_bg, + Palette::light().del_line_bg ); assert_ne!( - Palette::for_theme(Theme::Light).del_subtle, - Palette::dark().del_subtle + Palette::for_theme(Theme::Light).del_line_bg, + Palette::dark().del_line_bg ); assert_eq!( - Palette::for_theme(Theme::Dark).del_subtle, - Palette::dark().del_subtle + 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. assert_eq!( - Palette::for_theme(Theme::Auto).del_subtle, - Palette::dark().del_subtle + Palette::for_theme(Theme::Auto).del_line_bg, + Palette::dark().del_line_bg + ); + } + + #[test] + fn relative_luminance_of_black_and_white_are_the_wcag_endpoints() { + assert_eq!(relative_luminance(Color::Rgb(0, 0, 0)), Some(0.0)); + assert!((relative_luminance(Color::Rgb(255, 255, 255)).unwrap() - 1.0).abs() < 1e-9); + assert_eq!(relative_luminance(Color::Reset), None); + } + + #[test] + fn contrast_ratio_of_black_and_white_is_the_wcag_maximum() { + // The canonical WCAG example: pure black against pure white is 21:1. + let ratio = contrast_ratio(Color::Rgb(0, 0, 0), Color::Rgb(255, 255, 255)).unwrap(); + assert!((ratio - 21.0).abs() < 1e-6); + // Order-independent. + let reversed = contrast_ratio(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0)).unwrap(); + assert_eq!(ratio, reversed); + assert_eq!(contrast_ratio(Color::Reset, Color::Rgb(0, 0, 0)), None); + } + + #[test] + fn staged_foreground_dims_by_the_nominal_ratio_when_the_floor_is_cleared() { + // A saturated accent against a near-black background: the 40% dim easily clears the + // 3.0 floor against a very dark edit wash, so the nominal ratio wins outright. + let accent = Color::Rgb(0x99, 0xcc, 0x99); // base0B green + let background = Color::Rgb(0x2d, 0x2d, 0x2d); // base00 dark + let edit_bg = Color::Rgb(10, 10, 10); + let result = staged_foreground(accent, background, edit_bg); + assert_eq!(result, tint_toward(accent, background, STAGED_FG_DIM_RATIO)); + assert!(contrast_ratio(result, edit_bg).unwrap() >= STAGED_FG_LUMINANCE_FLOOR); + } + + #[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 + // 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. + let accent = Color::Rgb(0x99, 0xcc, 0x99); // base0B green + let background = Color::Rgb(0x2d, 0x2d, 0x2d); // base00 dark + // A mid-gray edit wash: the undimmed accent clears the floor against it, but the nominal + // 40% dim (which drags the accent's luminance toward the dark background) doesn't. + let edit_bg = Color::Rgb(80, 80, 80); + let nominal = tint_toward(accent, background, STAGED_FG_DIM_RATIO); + assert!( + contrast_ratio(nominal, edit_bg).unwrap() < STAGED_FG_LUMINANCE_FLOOR, + "the nominal dim must actually fail the floor here, or this test isn't exercising \ + the back-off path" ); + let result = staged_foreground(accent, background, edit_bg); + assert_ne!( + result, nominal, + "must back off from the failing nominal dim" + ); + assert!( + contrast_ratio(result, edit_bg).unwrap() >= STAGED_FG_LUMINANCE_FLOOR, + "the backed-off foreground must clear the floor" + ); + } + + #[test] + fn staged_foreground_uses_undimmed_when_even_that_fails_the_floor() { + // If the fully undimmed accent already fails the floor against `edit_bg`, the derivation + // must not invent a hue to force compliance — it returns the undimmed accent as-is. + let accent = Color::Rgb(100, 100, 100); + let background = Color::Rgb(0x2d, 0x2d, 0x2d); + let edit_bg = Color::Rgb(105, 105, 105); // near-identical luminance to `accent` + assert!(contrast_ratio(accent, edit_bg).unwrap() < STAGED_FG_LUMINANCE_FLOOR); + assert_eq!(staged_foreground(accent, background, edit_bg), accent); + } + + #[test] + fn staged_foreground_skips_the_clamp_for_non_rgb_input() { + // Non-RGB colors have no luminance to clamp against — the derivation must not panic and + // must fall through to the nominal dim (which itself passes through `tint_toward` + // unblended for non-RGB, per that function's own contract). + assert_eq!( + staged_foreground(Color::Reset, Color::Rgb(0, 0, 0), Color::Rgb(0, 0, 0)), + Color::Reset + ); + } + + #[test] + fn dark_and_light_foregrounds_role_map_to_the_add_del_accents() { + let dark = Palette::dark(); + assert_eq!(dark.add_fg, Base16::EIGHTIES_DARK.slot(11)); // base0B + assert_eq!(dark.del_fg, Base16::EIGHTIES_DARK.slot(8)); // base08 + let light = Palette::light(); + assert_eq!(light.add_fg, Base16::ONE_LIGHT.slot(11)); + assert_eq!(light.del_fg, Base16::ONE_LIGHT.slot(8)); + } + + /// 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). + 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 { + assert_eq!( + result, accent, + "a floor-failing result is only acceptable if it's the undimmed accent" + ); + assert!(contrast_ratio(accent, edit_bg).unwrap() < STAGED_FG_LUMINANCE_FLOOR); + } + } + + #[test] + fn dark_staged_foregrounds_clear_the_contrast_floor_and_are_visibly_dimmed() { + // `dark()`'s staged edit washes have enough headroom that both staged foregrounds derive + // via the nominal-or-backoff path, not the "undimmed already fails" fallback. + let t = Palette::dark(); + assert_staged_foreground_contract(t.add_staged_fg, t.add_fg, t.add_staged_edit_bg); + assert_staged_foreground_contract(t.del_staged_fg, t.del_fg, t.del_staged_edit_bg); + assert_ne!(t.add_staged_fg, t.add_fg); + assert_ne!(t.del_staged_fg, t.del_fg); + } + + #[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 + // 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(); + assert_staged_foreground_contract(t.add_staged_fg, t.add_fg, t.add_staged_edit_bg); + assert_staged_foreground_contract(t.del_staged_fg, t.del_fg, t.del_staged_edit_bg); + } + + #[test] + fn mono_foregrounds_are_all_reset() { + for light in [false, true] { + let t = Palette::mono(light); + assert_eq!(t.add_fg, Color::Reset); + assert_eq!(t.del_fg, Color::Reset); + assert_eq!(t.add_staged_fg, Color::Reset); + assert_eq!(t.del_staged_fg, Color::Reset); + } + } + + #[test] + fn apply_overrides_base01_and_base02_rewrite_filler_fg_and_selection_bg() { + // CS11: 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 + let mut t = Palette::dark(); + t.apply_overrides(&overrides); + assert_eq!(t.filler_fg, Color::Rgb(0x11, 0x11, 0x11)); + assert_eq!(t.selection_bg, Color::Rgb(0x22, 0x22, 0x22)); + } + + #[test] + fn apply_overrides_base02_is_beaten_by_an_explicit_selection_bg_tint_override() { + // The no-clobber invariant: slot arms run before tint arms, so an explicit + // `selection-bg` override still wins over a `base02` slot override. + let mut overrides = ThemeOverrides::default(); + overrides.set_slot(2, Color::Rgb(0x22, 0x22, 0x22)); + overrides.selection_bg = Some(Color::Rgb(0x33, 0x33, 0x33)); + let mut t = Palette::dark(); + t.apply_overrides(&overrides); + assert_eq!(t.selection_bg, Color::Rgb(0x33, 0x33, 0x33)); + } + + #[test] + fn apply_overrides_reads_the_four_new_fg_tint_keys_verbatim() { + let overrides = ThemeOverrides { + add_fg: Some(Color::Rgb(1, 2, 3)), + del_fg: Some(Color::Rgb(4, 5, 6)), + add_staged_fg: Some(Color::Rgb(7, 8, 9)), + del_staged_fg: Some(Color::Rgb(10, 11, 12)), + ..Default::default() + }; + let mut t = Palette::dark(); + t.apply_overrides(&overrides); + assert_eq!(t.add_fg, Color::Rgb(1, 2, 3)); + assert_eq!(t.del_fg, Color::Rgb(4, 5, 6)); + assert_eq!(t.add_staged_fg, Color::Rgb(7, 8, 9)); + assert_eq!(t.del_staged_fg, Color::Rgb(10, 11, 12)); + } + + #[test] + fn parse_hex_color_accepts_hash_and_bare_six_digit_hex() { + assert_eq!( + parse_hex_color("#2d2d2d"), + Some(Color::Rgb(0x2d, 0x2d, 0x2d)) + ); + assert_eq!( + parse_hex_color("2d2d2d"), + Some(Color::Rgb(0x2d, 0x2d, 0x2d)) + ); + } + + #[test] + fn parse_hex_color_rejects_shorthand_invalid_and_empty() { + assert_eq!(parse_hex_color("#fff"), None, "3-digit shorthand rejected"); + assert_eq!(parse_hex_color("2d2d2g"), None, "non-hex digit rejected"); + assert_eq!(parse_hex_color(""), None, "empty rejected"); + } + + #[test] + fn empty_overrides_is_an_identity_on_dark() { + // Pixel-identity precedent (same as `dark_diff_tints_match_the_historical_constants`): + // an empty `ThemeOverrides` must leave every field untouched. + let overrides = ThemeOverrides::default(); + assert!(overrides.is_empty()); + let mut t = Palette::dark(); + let before = Palette::dark(); + t.apply_overrides(&overrides); + assert_eq!(t.background, before.background); + assert_eq!(t.foreground, before.foreground); + assert_eq!(t.error_fg, before.error_fg); + assert_eq!(t.heading_fg, before.heading_fg); + assert_eq!(t.del_line_bg, before.del_line_bg); + assert_eq!(t.cursor_bg, before.cursor_bg); + assert_eq!(t.paint_canvas, before.paint_canvas); + assert_eq!( + t.syntax(capture_index("keyword").unwrap()), + before.syntax(capture_index("keyword").unwrap()) + ); + } + + #[test] + fn apply_overrides_base0e_recolors_the_keyword_syntax_capture() { + let mut overrides = ThemeOverrides::default(); + overrides.set_slot(14, Color::Rgb(0x11, 0x22, 0x33)); // base0E → keyword + let mut t = Palette::dark(); + t.apply_overrides(&overrides); + assert_eq!( + t.syntax(capture_index("keyword").unwrap()), + Color::Rgb(0x11, 0x22, 0x33) + ); + // Unrelated captures are untouched. + assert_eq!( + t.syntax(capture_index("string").unwrap()), + Palette::dark().syntax(capture_index("string").unwrap()) + ); + } + + #[test] + fn apply_overrides_base08_rewrites_error_fg_even_on_darks_hand_authored_value() { + // The uniform rule (see `Palette::apply_overrides`'s doc comment): a slot override + // rewrites its role-mapped field even when the base authored that field explicitly + // (`dark()`'s `error_fg` is a hand-tuned literal, not derived from base08). + let mut overrides = ThemeOverrides::default(); + overrides.set_slot(8, Color::Rgb(0xaa, 0xbb, 0xcc)); // base08 → error_fg + let mut t = Palette::dark(); + t.apply_overrides(&overrides); + assert_eq!(t.error_fg, Color::Rgb(0xaa, 0xbb, 0xcc)); + assert_ne!(t.error_fg, Palette::dark().error_fg); + } + + #[test] + fn apply_overrides_base00_on_a_from_terminal_palette_sets_paint_canvas() { + // `auto`'s probe result leaves `paint_canvas: false`; an explicit base00 override means + // the user chose a background, so it must paint even under `auto`. + let probed = probed_base16(Color::Rgb(0x1a, 0x1a, 0x1a)); + let mut t = Palette::from_terminal(probed); + assert!(!t.paint_canvas); + let mut overrides = ThemeOverrides::default(); + overrides.set_slot(0, Color::Rgb(0x10, 0x10, 0x10)); + t.apply_overrides(&overrides); + assert_eq!(t.background, Color::Rgb(0x10, 0x10, 0x10)); + assert!(t.paint_canvas); + } + + #[test] + fn apply_overrides_tint_lands_verbatim_unaffected_by_slot_overrides() { + let mut overrides = ThemeOverrides::default(); + overrides.set_slot(8, Color::Rgb(0xaa, 0xbb, 0xcc)); // base08 → error_fg, NOT del_line_bg + overrides.cursor_bg = Some(Color::Rgb(0x01, 0x02, 0x03)); + let mut t = Palette::dark(); + t.apply_overrides(&overrides); + assert_eq!(t.cursor_bg, Color::Rgb(0x01, 0x02, 0x03)); + // The del/add tints are untouched by the base08 slot override — tint overrides are the + // only thing that moves them. + assert_eq!(t.del_line_bg, Palette::dark().del_line_bg); + } + + #[test] + fn mono_fg_and_syntax_fields_are_all_reset() { + let t = Palette::mono(false); + assert_eq!(t.background, Color::Reset); + assert_eq!(t.foreground, Color::Reset); + assert_eq!(t.dim, Color::Reset); + assert_eq!(t.gutter, Color::Reset); + assert_eq!(t.error_fg, Color::Reset); + assert_eq!(t.warn_fg, Color::Reset); + assert_eq!(t.current_fg, Color::Reset); + assert_eq!(t.heading_fg, Color::Reset); + assert_eq!(t.modified_fg, Color::Reset); + assert!(!t.paint_canvas); + for capture in 0..syntax_slot_count() { + assert_eq!( + t.syntax(capture), + Color::Reset, + "capture {capture} not Reset" + ); + } + } + + /// A wash must be an achromatic (`r == g == b`) `Rgb` — never `Reset`, since the 11 washes + /// still need to carry cursor/selection/staged attribution (unlike the fg fields above). + fn assert_achromatic(color: Color) { + let (r, g, b) = rgb(color); + assert_eq!(r, g, "not achromatic: {color:?}"); + assert_eq!(g, b, "not achromatic: {color:?}"); + } + + #[test] + fn mono_washes_are_achromatic_and_preserve_the_curated_invariants() { + for light in [false, true] { + let t = Palette::mono(light); + for wash in [ + t.del_line_bg, + t.del_edit_bg, + t.add_line_bg, + t.add_edit_bg, + t.del_staged_line_bg, + t.del_staged_edit_bg, + t.add_staged_line_bg, + t.add_staged_edit_bg, + t.cursor_bg, + t.selection_bg, + t.cursor_unfocused_bg, + ] { + assert_achromatic(wash); + } + + // Add and Del share one gray ladder (accepted degradation — hue can't carry + // add-vs-del in colorless mode, so gutter structure does instead). + assert_eq!(t.del_line_bg, t.add_line_bg); + assert_eq!(t.del_edit_bg, t.add_edit_bg); + assert_eq!(t.del_staged_line_bg, t.add_staged_line_bg); + assert_eq!(t.del_staged_edit_bg, t.add_staged_edit_bg); + + // Line vs edit remain visibly distinct steps. + assert_ne!(t.del_line_bg, t.del_edit_bg); + assert_ne!(t.del_staged_line_bg, t.del_staged_edit_bg); + + // Staged reads dimmer (closer to the implied background — brighter grays near a + // light bg, darker grays near a dark bg) than unstaged. + let (staged_line, _, _) = rgb(t.del_staged_line_bg); + let (line, _, _) = rgb(t.del_line_bg); + let (staged_edit, _, _) = rgb(t.del_staged_edit_bg); + let (edit, _, _) = rgb(t.del_edit_bg); + if light { + assert!(staged_line > line, "staged should sit closer to white"); + assert!(staged_edit > edit, "staged should sit closer to white"); + } else { + assert!(staged_line < line, "staged should sit closer to black"); + assert!(staged_edit < edit, "staged should sit closer to black"); + } + + // Cursor vs selection are distinct, and the unfocused cursor wash reads dimmer + // (closer to the implied background) than the focused cursor wash. + assert_ne!(t.cursor_bg, t.selection_bg); + let (cursor, _, _) = rgb(t.cursor_bg); + let (cursor_unfocused, _, _) = rgb(t.cursor_unfocused_bg); + if light { + assert!(cursor_unfocused > cursor); + } else { + assert!(cursor_unfocused < cursor); + } + } + } + + #[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` + // 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). + for light in [false, true] { + let t = Palette::mono(light); + assert_eq!(t.pane_header_focused_fg, Color::Reset); + assert_eq!(t.pane_header_focused_fg, t.dim); + } + } + + #[test] + fn only_mono_sets_colorless() { + // `colorless` is the flag `render.rs`'s icon paint sites consult to collapse + // palette-external colors (nerd-font icons) to `foreground` under NO_COLOR — it must be + // true ONLY for `mono`, false for every curated/probed constructor. + assert!(!Palette::dark().colorless); + assert!(!Palette::light().colorless); + assert!(!Palette::from_terminal(probed_base16(Color::Rgb(0x1a, 0x1a, 0x1a))).colorless); + assert!(Palette::mono(false).colorless); + assert!(Palette::mono(true).colorless); + } + + #[test] + fn mono_dark_ladder_sits_near_black_and_light_ladder_near_white() { + let (r, _, _) = rgb(Palette::mono(false).del_edit_bg); + assert!(r < 128, "dark ladder should be a dark gray"); + let (r, _, _) = rgb(Palette::mono(true).del_edit_bg); + assert!(r > 128, "light ladder should be a pale gray"); } } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 5fd518b2..17d3486b 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -45,11 +45,12 @@ use ratatui::widgets::Paragraph; use ratatui::{Frame, Terminal}; use workon::Changeset; use workon_review::acquire::{diff_changeset, ChangesetDiff}; -use workon_review::app::{self, App, FileLoadSpec, LoadedViews}; +use workon_review::app::{self, App, FileLoadSpec, LoadedViews, Severity}; +use workon_review::config; use workon_review::highlight::TsHighlighter; use workon_review::keymap::{Command, Dispatch, KeyPress, Keymap}; use workon_review::render; -use workon_review::theme::Palette; +use workon_review::theme::{Palette, PaletteContext}; /// One event the review loop reacts to. `Tick` is synthesized by the main loop on an inbox /// `recv_timeout` timeout — it is never sent through the channel itself (see [`recv_event`]). @@ -427,6 +428,7 @@ fn drain_pending( enum Action { Quit, ToggleHelp, + ReloadConfig, MoveCursorBy(i64), ScrollTop, ScrollBottom, @@ -447,6 +449,8 @@ enum Action { StartSelection, ExpandGap, ExpandGapAll, + ResetGaps, + ExpandAllGaps, HscrollLeft, HscrollRight, ToggleOutline, @@ -478,6 +482,7 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::Quit => Action::Quit, Command::ToggleOutline => Action::ToggleOutline, Command::ToggleHelp => Action::ToggleHelp, + Command::ReloadConfig => Action::ReloadConfig, Command::CursorDown => Action::MoveCursorBy(1), Command::CursorUp => Action::MoveCursorBy(-1), Command::HalfPageDown => Action::MoveCursorBy(half_page), @@ -495,6 +500,8 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::StartSelection => Action::StartSelection, Command::ExpandGap => Action::ExpandGap, Command::ExpandGapAll => Action::ExpandGapAll, + Command::ResetGaps => Action::ResetGaps, + Command::ExpandAllGaps => Action::ExpandAllGaps, Command::HscrollLeft => Action::HscrollLeft, Command::HscrollRight => Action::HscrollRight, Command::NextFile => Action::NextFile, @@ -593,6 +600,8 @@ fn action_needs_loaded_view(action: Action) -> bool { | Action::ToggleSplitFocus | Action::ExpandGap | Action::ExpandGapAll + | Action::ResetGaps + | Action::ExpandAllGaps ) } @@ -610,6 +619,7 @@ fn apply_action(app: &mut App, action: Action) -> bool { match action { Action::Quit => return true, Action::ToggleHelp => app.toggle_help(), + Action::ReloadConfig => app.request_config_reload(), Action::MoveCursorBy(delta) => app.move_cursor_by(delta), Action::ScrollTop => app.scroll_top(), Action::ScrollBottom => app.scroll_bottom(), @@ -630,6 +640,8 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::StartSelection => app.start_selection(), Action::ExpandGap => app.expand_gap_at_cursor(false), Action::ExpandGapAll => app.expand_gap_at_cursor(true), + Action::ResetGaps => app.reset_gaps(), + Action::ExpandAllGaps => app.expand_all_gaps(), Action::HscrollLeft => app.hscroll_left(), Action::HscrollRight => app.hscroll_right(), Action::ToggleOutline => app.toggle_outline(), @@ -1032,12 +1044,18 @@ impl Tui { /// the tty before crossterm's event stream has a reader racing them. Neither thread is joined: /// when `run` returns, `main` returns, and the process takes both down (ADR-037's kill-on-exit /// lifecycle — neither thread ever writes, so an abandoned one can't corrupt anything). + /// + /// `keymap`/`theme` are taken BY VALUE (not `&Keymap`/`&Palette`) — a `reload-config` request + /// (`R`) needs to swap both mid-session, which needs owned locals `event_loop` can hold a + /// `&mut` into; `palette_ctx` is what a reload re-resolves `theme = auto` against (see + /// [`PaletteContext`]'s doc comment) rather than re-probing the terminal. pub fn run( &mut self, app: &mut App, - keymap: &Keymap, - theme: &Palette, + mut keymap: Keymap, + mut theme: Palette, repo_path: PathBuf, + palette_ctx: &PaletteContext, ) -> io::Result<()> { let (tx, rx) = mpsc::channel::(); spawn_input_thread(tx.clone()); @@ -1048,7 +1066,14 @@ impl Tui { wave_tx: &tx, repo_path: &repo_path, }; - let result = event_loop(&mut self.terminal, app, keymap, theme, &pipeline); + let result = event_loop( + &mut self.terminal, + app, + &mut keymap, + &mut theme, + palette_ctx, + &pipeline, + ); let restored = self.restore(); result.and(restored) } @@ -1066,10 +1091,11 @@ impl Tui { pub fn run_streamed( &mut self, app: &mut App, - keymap: &Keymap, - theme: &Palette, + mut keymap: Keymap, + mut theme: Palette, repo_path: PathBuf, changesets: Vec, + palette_ctx: &PaletteContext, ) -> io::Result<()> { let (tx, rx) = mpsc::channel::(); spawn_input_thread(tx.clone()); @@ -1091,7 +1117,14 @@ impl Tui { wave_tx: &tx, repo_path: &repo_path, }; - let result = event_loop(&mut self.terminal, app, keymap, theme, &pipeline); + let result = event_loop( + &mut self.terminal, + app, + &mut keymap, + &mut theme, + palette_ctx, + &pipeline, + ); let restored = self.restore(); result.and(restored) } @@ -1144,8 +1177,9 @@ const OPEN_DEBOUNCE: Duration = Duration::from_millis(80); fn event_loop( terminal: &mut Terminal>, app: &mut App, - keymap: &Keymap, - theme: &Palette, + keymap: &mut Keymap, + theme: &mut Palette, + palette_ctx: &PaletteContext, pipeline: &Pipeline<'_>, ) -> io::Result<()> { let Pipeline { @@ -1212,6 +1246,31 @@ fn event_loop( Some(app.current_cs()), ); } + + // `reload-config` (`R`): re-read the whole `workon.review.*` tree through `App`'s own + // repo handle and swap it into the keymap/palette the render/dispatch calls above already + // hold `&mut` into — `App` itself flagged this via `request_config_reload` (it can't do + // the swap itself, see that method's doc comment). The immutable `app.repo()` borrow ends + // with `resolve_runtime`'s return, before `app` is touched mutably below. + if app.take_config_reload_request() { + let runtime = config::resolve_runtime(app.repo(), palette_ctx); + *keymap = runtime.keymap; + *theme = runtime.palette; + // A half-entered chord against the OLD keymap is meaningless once the bindings under + // it have changed. + pending.clear(); + 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) { + app.notify("config reloaded", Severity::Info); + } + } } } @@ -1553,11 +1612,14 @@ mod tests { } #[test] - fn z_and_w_map_to_zoom_and_split_focus() { + 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` + // 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), + map_key(&km, &mut pending, key(KeyCode::Char('Z')), 20, false, false), Action::CycleZoom ); assert_eq!( @@ -1566,6 +1628,30 @@ mod tests { ); } + #[test] + fn z_m_and_z_r_map_to_reset_and_expand_all_gaps() { + 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::None, + "the first key of a chord reports no action yet (Pending)" + ); + assert_eq!( + map_key(&km, &mut pending, key(KeyCode::Char('M')), 20, false, false), + Action::ResetGaps + ); + + assert_eq!( + map_key(&km, &mut pending, key(KeyCode::Char('z')), 20, false, false), + Action::None + ); + assert_eq!( + map_key(&km, &mut pending, key(KeyCode::Char('R')), 20, false, false), + Action::ExpandAllGaps + ); + } + #[test] fn r_maps_to_refresh() { let km = Keymap::defaults(); @@ -3359,6 +3445,28 @@ mod tests { ); } + // ── `reload-config` (`R`) ─────────────────────────────────────────────────── + + #[test] + fn reload_config_command_maps_to_the_reload_action_and_sets_the_app_flag() { + assert_eq!( + command_to_action(Command::ReloadConfig, 20), + Action::ReloadConfig + ); + + use git_workon_fixture::prelude::*; + let fixture = FixtureBuilder::new().build().unwrap(); + let mut app = app_from_fixture(&fixture); + assert!(!app.take_config_reload_request()); + + apply_action(&mut app, Action::ReloadConfig); + assert!( + app.take_config_reload_request(), + "Action::ReloadConfig must raise App's request flag" + ); + assert!(!app.take_config_reload_request(), "the flag is one-shot"); + } + // ── diff-hscroll: `Action::FocusOutline` pans home before focusing ───────────── /// Locked decision #2: `h`/`left` (`Action::FocusOutline`) pans the diff back toward column diff --git a/git-workon-review/tests/pty/main.rs b/git-workon-review/tests/pty/main.rs new file mode 100644 index 00000000..ee557f73 --- /dev/null +++ b/git-workon-review/tests/pty/main.rs @@ -0,0 +1,12 @@ +//! Single integration-test harness binary for `git-workon-review`'s PTY suite — kept SEPARATE +//! from `../suite/main.rs` (the rest of the crate's integration tests) because both PTY files +//! are unix-only (`#![cfg(unix)]`, hoisted here since inner attributes are only legal at the +//! binary's crate root) and `#[ignore]`d by default (wall-clock-bound, load-sensitive; see the +//! module doc comments). Run explicitly: `cargo test -p git-workon-review --test pty -- +//! --ignored`. + +#![cfg(unix)] + +mod pty_responsiveness; +mod pty_smoke; +mod pty_support; diff --git a/git-workon-review/tests/pty_responsiveness.rs b/git-workon-review/tests/pty/pty_responsiveness.rs similarity index 98% rename from git-workon-review/tests/pty_responsiveness.rs rename to git-workon-review/tests/pty/pty_responsiveness.rs index 7b59c1be..f8a3d5c9 100644 --- a/git-workon-review/tests/pty_responsiveness.rs +++ b/git-workon-review/tests/pty/pty_responsiveness.rs @@ -30,17 +30,14 @@ //! explicitly: //! //! ```text -//! cargo test -p git-workon-review --test pty_responsiveness -- --ignored +//! cargo test -p git-workon-review --test pty -- --ignored //! ``` //! //! Frame-content assertions are deliberately absent — capturing ratatui frame TEXT through a //! PTY is unreliable (only escape sequences survive dependably); rendering is covered by the //! `TestBackend` tests in `render.rs`/`tui.rs`. -#![cfg(unix)] - -mod pty_support; -use pty_support::spawn_review; +use crate::pty_support::spawn_review; use std::time::{Duration, Instant}; @@ -124,7 +121,7 @@ fn rust_source(seed: usize, lines: usize) -> String { } #[test] -#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty_responsiveness -- --ignored"] +#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty -- --ignored"] 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 @@ -160,7 +157,7 @@ fn launch_reaches_the_tui_and_quits_promptly() { } #[test] -#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty_responsiveness -- --ignored"] +#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty -- --ignored"] fn rapid_outline_nav_burst_stays_responsive() { // Dozens of untracked multi-thousand-line Rust files: every outline row the burst crosses // is a file whose (regressed) synchronous load would cost real tree-sitter work. @@ -241,7 +238,7 @@ fn commit_onto( /// that sized the bound and confirm this assertion fails on the regressed shape, the same /// validation discipline `BURST_RESPONSIVE`'s doc comment describes. #[test] -#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty_responsiveness -- --ignored"] +#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty -- --ignored"] fn streamed_startup_lands_before_a_full_wave_could_have_finished() { use workon::{assemble_changesets, StackModel, UncommittedLayer}; diff --git a/git-workon-review/tests/pty_smoke.rs b/git-workon-review/tests/pty/pty_smoke.rs similarity index 76% rename from git-workon-review/tests/pty_smoke.rs rename to git-workon-review/tests/pty/pty_smoke.rs index 0d9902fa..197db8ba 100644 --- a/git-workon-review/tests/pty_smoke.rs +++ b/git-workon-review/tests/pty/pty_smoke.rs @@ -14,16 +14,13 @@ //! test: re-run solo before treating a failure as a regression). Run them explicitly: //! //! ```text -//! cargo test -p git-workon-review --test pty_smoke -- --ignored +//! cargo test -p git-workon-review --test pty -- --ignored //! ``` //! //! Color/SGR assertions are deliberately absent — capturing ratatui frames through a PTY is //! unreliable; reply *parsing* is unit-tested in `terminal_query.rs`. -#![cfg(unix)] - -mod pty_support; -use pty_support::spawn_review; +use crate::pty_support::{probe_cache_path, spawn_review}; use std::io::Write; use std::time::{Duration, Instant}; @@ -97,7 +94,7 @@ fn assert_q_quits_promptly(mut session: Session) { } #[test] -#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty_smoke -- --ignored"] +#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty -- --ignored"] fn theme_auto_stays_responsive_when_the_terminal_answers() { let fixture = auto_theme_fixture(); let mut session = spawn_review(&fixture); @@ -113,7 +110,7 @@ fn theme_auto_stays_responsive_when_the_terminal_answers() { } #[test] -#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty_smoke -- --ignored"] +#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty -- --ignored"] fn theme_auto_stays_responsive_when_the_terminal_is_silent() { // The no-hang guarantee: a terminal that never answers (tmux without passthrough, CI) must // cost at most the probe deadline, then fall back to a curated theme and run normally. This @@ -125,6 +122,10 @@ fn theme_auto_stays_responsive_when_the_terminal_is_silent() { assert_q_quits_promptly(session); + // NOT asserted here: what tty name the recorded verdict is keyed by — that is + // `silent_probe_verdict_is_keyed_to_the_concrete_tty_device`'s job, in its own PTY so the + // two tests never share a cache file's write timing. + // // NOT asserted here: that a SECOND launch on this same (now cache-hit) terminal is fast. // That behavior is real (manually verified end-to-end with the actual binary under `expect` // — a first silent launch pays the ~800ms deadline and records a verdict; a second launch on @@ -139,3 +140,32 @@ fn theme_auto_stays_responsive_when_the_terminal_is_silent() { // workflow for this one behavior, same posture pty_responsiveness.rs takes for precise // per-phase timings. } + +#[test] +#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty -- --ignored"] +fn silent_probe_verdict_is_keyed_to_the_concrete_tty_device() { + // The 2026-07 auto-theme-goes-dark bug: `terminal_key()` resolved the tty name from an fd + // opened on `/dev/tty`, which macOS's `ttyname_r` reports as the literal "/dev/tty" — one + // constant key shared by EVERY terminal window. A single silent verdict (recorded by a + // dogfood run under `expect`, whose pty answers no OSC queries) then matched every real + // terminal with the same TERM/TERM_PROGRAM, so `theme = auto` silently fell back to curated + // dark for the cache's whole 30-day TTL. The verdict must instead be keyed to this PTY's + // concrete device (`/dev/ttysNNN` on macOS, `/dev/pts/N` on Linux) so it scopes to the one + // terminal that actually went silent. + let fixture = auto_theme_fixture(); + let session = spawn_review(&fixture); + assert_q_quits_promptly(session); // silent PTY: the probe times out and records its verdict + + let cache = std::fs::read_to_string(probe_cache_path(&fixture)) + .expect("silent launch must have recorded a probe-cache verdict"); + let entries: serde_json::Value = serde_json::from_str(&cache).expect("cache is JSON"); + let tty = entries[0]["tty"].as_str().expect("verdict has a tty key"); + assert_ne!( + tty, "/dev/tty", + "verdict keyed to the non-scoping /dev/tty poisons every terminal window" + ); + assert!( + tty.starts_with("/dev/"), + "verdict tty {tty:?} is not a device path" + ); +} diff --git a/git-workon-review/tests/pty_support/mod.rs b/git-workon-review/tests/pty/pty_support/mod.rs similarity index 62% rename from git-workon-review/tests/pty_support/mod.rs rename to git-workon-review/tests/pty/pty_support/mod.rs index 1887a730..d311f976 100644 --- a/git-workon-review/tests/pty_support/mod.rs +++ b/git-workon-review/tests/pty/pty_support/mod.rs @@ -1,9 +1,11 @@ -//! Shared PTY-test support for the `pty_smoke` and `pty_responsiveness` test binaries. +//! Shared PTY-test support for the `pty_smoke` and `pty_responsiveness` modules of the `pty` +//! integration-test binary (`tests/pty/main.rs`). //! -//! A `tests//mod.rs` directory module so cargo does not build it as a test binary of its -//! own; each PTY suite declares `mod pty_support;`. Keeping the spawn setup in one place means -//! a change to the window size, `TERM`, or expect timeout applies to every PTY suite at once — -//! the two suites guard related regressions, so silent drift here would matter. +//! A `tests/pty//mod.rs` directory module so cargo does not build it as a test binary of +//! its own; `main.rs` declares `mod pty_support;` once and the suite modules reach it via +//! `crate::pty_support`. Keeping the spawn setup in one place means a change to the window size, +//! `TERM`, or expect timeout applies to every PTY suite at once — the two suites guard related +//! regressions, so silent drift here would matter. use std::time::Duration; @@ -26,9 +28,8 @@ use git_workon_fixture::prelude::*; /// fixture's workdir means repeat `spawn_review` calls against the SAME fixture share one cache /// file, while different fixtures — different tempdirs — never collide. pub fn spawn_review(fixture: &Fixture) -> Session { - let repo = fixture.repo().expect("fixture repo"); - let workdir = repo.workdir().expect("fixture workdir").to_path_buf(); - let probe_cache = workdir.join(".git-workon-review-probe-cache.json"); + let workdir = fixture_workdir(fixture); + let probe_cache = probe_cache_path(fixture); let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_git-workon-review")); cmd.current_dir(&workdir) @@ -43,3 +44,15 @@ pub fn spawn_review(fixture: &Fixture) -> Session { session.set_expect_timeout(Some(Duration::from_secs(15))); session } + +/// The probe-cache file [`spawn_review`] pins for `fixture` — one shared definition so a test +/// that inspects what the binary recorded reads the same path the spawn wired up. +pub fn probe_cache_path(fixture: &Fixture) -> std::path::PathBuf { + fixture_workdir(fixture).join(".git-workon-review-probe-cache.json") +} + +fn fixture_workdir(fixture: &Fixture) -> std::path::PathBuf { + let repo = fixture.repo().expect("fixture repo"); + let workdir = repo.workdir().expect("fixture workdir"); + workdir.to_path_buf() +} diff --git a/git-workon-review/tests/apply.rs b/git-workon-review/tests/suite/apply.rs similarity index 100% rename from git-workon-review/tests/apply.rs rename to git-workon-review/tests/suite/apply.rs diff --git a/git-workon-review/tests/cli.rs b/git-workon-review/tests/suite/cli.rs similarity index 100% rename from git-workon-review/tests/cli.rs rename to git-workon-review/tests/suite/cli.rs diff --git a/git-workon-review/tests/diff_model.rs b/git-workon-review/tests/suite/diff_model.rs similarity index 100% rename from git-workon-review/tests/diff_model.rs rename to git-workon-review/tests/suite/diff_model.rs diff --git a/git-workon-review/tests/file_ops.rs b/git-workon-review/tests/suite/file_ops.rs similarity index 62% rename from git-workon-review/tests/file_ops.rs rename to git-workon-review/tests/suite/file_ops.rs index 152c5a84..bc1aab45 100644 --- a/git-workon-review/tests/file_ops.rs +++ b/git-workon-review/tests/suite/file_ops.rs @@ -13,6 +13,28 @@ use workon_review::model::LineKind; use workon_review::ops::{apply_file, apply_hunk, apply_lines}; use workon_review::synthesis::{LineSelection, PatchHunk, PatchLine, PatchText}; +/// Pipe `raw` into `git apply --cached` at `workdir`, returning the process output. Shared by +/// tests that exercise `CliApplier`'s mechanism directly, bypassing `PatchText`/`Applier`. +fn git_apply_cached(workdir: &std::path::Path, raw: &[u8]) -> std::process::Output { + use std::io::Write; + + let mut child = std::process::Command::new("git") + .args(["apply", "--cached"]) + .current_dir(workdir) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn git apply"); + child + .stdin + .as_mut() + .expect("stdin was piped") + .write_all(raw) + .expect("write patch to stdin"); + child.wait_with_output().expect("wait for git apply") +} + /// Hand-build the patch a naive whole-hunk stage of a DELETION would render: a hunk deleting /// every line, `--- a/` / `+++ b/` (not `/dev/null` — the file still exists at /// `path` in the index/HEAD, only its content is fully removed). This is what @@ -45,35 +67,6 @@ fn naive_deletion_hunk_patch(path: &str, committed_content: &str) -> PatchText { } } -/// Hand-build the patch a naive whole-hunk stage of an UNTRACKED file would render: an -/// all-additions hunk from `/dev/null` to `b/` — what `whole_hunk_patch` would produce if -/// it didn't refuse `FileStatus::Untracked`. -fn naive_untracked_hunk_patch(path: &str, content: &str) -> PatchText { - let lines: Vec = content - .lines() - .map(|line| PatchLine { - kind: LineKind::Addition, - content: format!("{line}\n").into_bytes(), - missing_newline: false, - }) - .collect(); - let count = lines.len() as u32; - PatchText { - old_path: None, - new_path: Some(path.to_string()), - old_mode: 0o100644, - new_mode: 0o100644, - hunks: vec![PatchHunk { - old_start: 0, - old_count: 0, - new_start: 1, - new_count: count, - header: format!("@@ -0,0 +1,{count} @@\n").into_bytes(), - lines, - }], - } -} - /// 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 @@ -105,34 +98,102 @@ fn naive_hunk_stage_of_deletion_stages_empty_blob() { fixture.assert(predicate::repo::index_blob_equals("gone.txt", b"".to_vec())); } -/// TRIPWIRE: a naive whole-hunk stage of an untracked file (from `/dev/null`) is REJECTED by -/// `git apply --cached` — the file isn't in the index yet, so there's no preimage to apply the -/// patch's context against ("... does not exist in index"). Verified directly against -/// `CliApplier`, bypassing `ops.rs`/`synthesis.rs` for the same reason as the deletion -/// tripwire above. +/// TRIPWIRE: a creation patch WITHOUT a `new file mode` header line (the shape +/// `PatchText::to_bytes` used to render for a one-sided patch — mode-suffixed `index` line, +/// `/dev/null` old side, but no mode line) is REJECTED by `git apply --cached`: git only sets +/// its is-new flag from the `new file mode` line, so this parses as a MODIFICATION of `new.txt` +/// and fails against the absent index preimage ("... does not exist in index"). This is the +/// exact rejection that motivated the one-sided header fix — the bytes are hand-crafted here +/// because `to_bytes` can no longer produce this broken shape (see +/// `creation_patch_with_proper_headers_is_accepted_by_both_appliers` below for the fixed one). #[test] fn naive_hunk_stage_of_untracked_errors() { + let raw: &[u8] = b"diff --git a/new.txt b/new.txt\n\ +index 0000000..0000000 100644\n\ +--- /dev/null\n\ ++++ b/new.txt\n\ +@@ -0,0 +1,1 @@\n\ ++hello\n"; + let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .untracked_file("new.txt", "hello\n") .build() .expect("fixture build"); let repo = fixture.repo().expect("repo"); + let workdir = repo.workdir().expect("workdir"); - let patch = naive_untracked_hunk_patch("new.txt", "hello\n"); - let result = CliApplier.apply( - repo, - &patch, - ApplyDestination::Index, - ApplyDirection::Forward, - ); + let output = git_apply_cached(workdir, raw); assert!( - result.is_err(), - "expected git apply --cached to reject the naive untracked hunk, got {result:?}" + !output.status.success(), + "expected git apply --cached to reject the mode-line-less creation patch, got: {}", + String::from_utf8_lossy(&output.stdout) ); } +/// Go/no-go (fork 1 of `docs/handoffs/2026-07-17-line-ops-one-sided-files.md`): a +/// properly-headed creation patch — `new file mode`, bare `index 0000000..0000000` (no mode +/// suffix), `/dev/null` old side, the canonical `git diff --no-index /dev/null file` shape — is +/// accepted by BOTH appliers. Deliberately bypasses `PatchText`/`Applier`: this pins the +/// MECHANISM (git accepts these headers) as a standing regression test, independent of whether +/// `PatchText::to_bytes` renders this shape (see `creation_patch_renders_new_file_mode_and_bare_index_line` +/// in `src/synthesis.rs` for that). Companion to (not a replacement of) +/// `naive_hunk_stage_of_untracked_errors` above, which pins the OLD (rejected) header shape. +#[test] +fn creation_patch_with_proper_headers_is_accepted_by_both_appliers() { + let raw: &[u8] = b"diff --git a/new.txt b/new.txt\n\ +new file mode 100644\n\ +index 0000000..0000000\n\ +--- /dev/null\n\ ++++ b/new.txt\n\ +@@ -0,0 +1,2 @@\n\ ++hello\n\ ++world\n"; + + // git2::Diff::from_buffer + Repository::apply(ApplyLocation::Index). + { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "hello\nworld\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diff = git2::Diff::from_buffer(raw).expect("git2 parses the proper creation header"); + repo.apply(&diff, git2::ApplyLocation::Index, None) + .expect("git2 applies the proper creation header to the index"); + + fixture.assert(predicate::repo::index_blob_equals( + "new.txt", + b"hello\nworld\n".to_vec(), + )); + } + + // `git apply --cached` directly (CliApplier's mechanism, bypassing PatchText). + { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "hello\nworld\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let workdir = repo.workdir().expect("workdir"); + + let output = git_apply_cached(workdir, raw); + assert!( + output.status.success(), + "git apply --cached rejected the proper creation header: {}", + String::from_utf8_lossy(&output.stderr) + ); + + fixture.assert(predicate::repo::index_blob_equals( + "new.txt", + b"hello\nworld\n".to_vec(), + )); + } +} + #[test] fn apply_lines_on_deleted_file_refuses() { let fixture = FixtureBuilder::new() @@ -158,54 +219,81 @@ fn apply_lines_on_deleted_file_refuses() { ); } +/// Flipped (was `apply_lines_on_untracked_file_refuses`): the old naive-header bug, not a real +/// git limitation (see the go/no-go test above and `src/synthesis.rs`'s one-sided-patch-header +/// rendering) — `apply_lines` now synthesizes a one-sided creation patch of just the kept lines. #[test] -fn apply_lines_on_untracked_file_refuses() { +fn apply_lines_on_untracked_file_stages_only_the_selected_lines() { let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") - .untracked_file("new.txt", "hello\n") + .untracked_file("new.txt", "hello\nworld\n") .build() .expect("fixture build"); let repo = fixture.repo().expect("repo"); let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); let file = &diffs.unstaged.files[0]; - let sel = LineSelection::default(); + let keep_add = file.hunks[0] + .lines + .iter() + .position(|l| l.kind == LineKind::Addition && l.content == b"hello\n") + .expect("hello line present"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [].into(), + }; let result = apply_lines(repo, &CliApplier, file, 0, &sel, StageVerb::Stage); assert!( - matches!( - result, - Err(ReviewError::Synthesis( - SynthesisError::LineSelectionUnsupported { .. } - )) - ), - "expected LineSelectionUnsupported, got {result:?}" + result.is_ok(), + "expected a line stage of an untracked file to succeed, got {result:?}" ); + fixture.assert(predicate::repo::index_blob_equals( + "new.txt", + b"hello\n".to_vec(), + )); + // Index-only apply: the untracked worktree file is untouched (still both lines). + fixture.assert(predicate::repo::workdir_file_equals( + "new.txt", + b"hello\nworld\n".to_vec(), + )); } +/// Flipped (was `apply_lines_on_added_file_refuses`) per fork 3: Added-file line-UNSTAGE is IN +/// SCOPE — the base=New machinery built for Untracked discard is exactly what unstage needs. A +/// partially staged untracked file immediately shows as Added in the staged pane, so this is +/// the same mechanism, just entered from the other side. #[test] -fn apply_lines_on_added_file_refuses() { +fn apply_lines_on_added_file_unstages_only_the_selected_lines() { let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") - .staged_file("added.txt", "hello\n") + .staged_file("added.txt", "hello\nworld\n") .build() .expect("fixture build"); let repo = fixture.repo().expect("repo"); let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); let file = &diffs.staged.files[0]; - let sel = LineSelection::default(); - let result = apply_lines(repo, &CliApplier, file, 0, &sel, StageVerb::Stage); + let keep_add = file.hunks[0] + .lines + .iter() + .position(|l| l.kind == LineKind::Addition && l.content == b"world\n") + .expect("world line present"); + let sel = LineSelection { + keep_adds: [keep_add].into(), + keep_dels: [].into(), + }; + let result = apply_lines(repo, &CliApplier, file, 0, &sel, StageVerb::Unstage); assert!( - matches!( - result, - Err(ReviewError::Synthesis( - SynthesisError::LineSelectionUnsupported { .. } - )) - ), - "expected LineSelectionUnsupported, got {result:?}" + result.is_ok(), + "expected a line unstage of an Added file to succeed, got {result:?}" ); + // "world\n" is unstaged (removed from the index); "hello\n" stays staged. + fixture.assert(predicate::repo::index_blob_equals( + "added.txt", + b"hello\n".to_vec(), + )); } #[test] @@ -408,3 +496,26 @@ fn apply_hunk_on_modified_text_file_passes_through_to_whole_hunk_stage() { b"line1\nCHANGED\nline3\n".to_vec(), )); } + +/// Regression guard: `is_hunk_patchable`/`apply_hunk`'s routing is deliberately UNCHANGED by the +/// line-ops-on-one-sided-files handoff — a hunk-level `s`/`d` (as opposed to a line-precise +/// selection) on an untracked file still falls back to the whole-file stage, since "the one hunk +/// IS the file" for these statuses. +#[test] +fn apply_hunk_on_untracked_file_still_stages_the_whole_file() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "hello\nworld\n") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + let file = &diffs.unstaged.files[0]; + apply_hunk(repo, &CliApplier, file, 0, StageVerb::Stage).expect("apply_hunk"); + + fixture.assert(predicate::repo::index_blob_equals( + "new.txt", + b"hello\nworld\n".to_vec(), + )); +} diff --git a/git-workon-review/tests/line_synthesis.rs b/git-workon-review/tests/suite/line_synthesis.rs similarity index 100% rename from git-workon-review/tests/line_synthesis.rs rename to git-workon-review/tests/suite/line_synthesis.rs diff --git a/git-workon-review/tests/suite/main.rs b/git-workon-review/tests/suite/main.rs new file mode 100644 index 00000000..0e92ba0e --- /dev/null +++ b/git-workon-review/tests/suite/main.rs @@ -0,0 +1,14 @@ +//! Single integration-test harness binary for `git-workon-review`. Cargo only auto-discovers +//! `tests/*.rs` as separate binaries, not files in subdirectories — declaring each test file as +//! a `mod` here merges them into one binary (one link instead of one per file), cutting build +//! time for the crate's non-PTY suite. See `../pty/main.rs` for why the PTY suite stays a +//! separate second binary. + +mod apply; +mod cli; +mod diff_model; +mod file_ops; +mod line_synthesis; +mod roundtrip_corpus; +mod source; +mod treesitter_smoke; diff --git a/git-workon-review/tests/roundtrip_corpus.rs b/git-workon-review/tests/suite/roundtrip_corpus.rs similarity index 73% rename from git-workon-review/tests/roundtrip_corpus.rs rename to git-workon-review/tests/suite/roundtrip_corpus.rs index cf6df13c..0e28924b 100644 --- a/git-workon-review/tests/roundtrip_corpus.rs +++ b/git-workon-review/tests/suite/roundtrip_corpus.rs @@ -188,10 +188,64 @@ fn scenarios() -> Vec { verify: unstage_staged_new_verify, }, Scenario { - name: "refusal_lines_on_untracked", + name: "untracked_partial_stage_contiguous", + build: untracked_multi_build, + ops: untracked_partial_stage_contiguous_ops, + verify: untracked_partial_stage_contiguous_verify, + }, + Scenario { + name: "untracked_partial_stage_noncontiguous", + build: untracked_multi_build, + ops: untracked_partial_stage_noncontiguous_ops, + verify: untracked_partial_stage_noncontiguous_verify, + }, + Scenario { + name: "untracked_partial_discard", + build: untracked_multi_build, + ops: untracked_partial_discard_ops, + verify: untracked_partial_discard_verify, + }, + Scenario { + name: "untracked_full_selection_discard", + build: untracked_multi_build, + ops: untracked_full_selection_discard_ops, + verify: untracked_full_selection_discard_verify, + }, + Scenario { + name: "untracked_eofnl_keep_final_line", + build: untracked_eofnl_build, + ops: untracked_eofnl_keep_final_line_ops, + verify: untracked_eofnl_keep_final_line_verify, + }, + Scenario { + name: "untracked_eofnl_drop_final_line", + build: untracked_eofnl_build, + ops: untracked_eofnl_drop_final_line_ops, + verify: untracked_eofnl_drop_final_line_verify, + }, + Scenario { + name: "executable_untracked_partial_stage", + build: executable_untracked_build, + ops: executable_untracked_partial_stage_ops, + verify: executable_untracked_partial_stage_verify, + }, + Scenario { + name: "untracked_stage_rest_reaches_fully_staged", + build: untracked_multi_build, + ops: untracked_stage_rest_reaches_fully_staged_ops, + verify: untracked_stage_rest_reaches_fully_staged_verify, + }, + Scenario { + name: "added_line_unstage", + build: added_multi_build, + ops: added_line_unstage_ops, + verify: added_line_unstage_verify, + }, + Scenario { + name: "refusal_lines_empty_selection_on_untracked", build: untracked_build, - ops: refusal_lines_on_untracked_ops, - verify: refusal_lines_on_untracked_verify, + ops: refusal_lines_empty_selection_on_untracked_ops, + verify: refusal_lines_empty_selection_on_untracked_verify, }, Scenario { name: "refusal_lines_on_deleted", @@ -764,11 +818,301 @@ fn unstage_staged_new_verify(fixture: &Fixture) { } // --------------------------------------------------------------------------------------------- -// refusals: apply_lines on untracked/deleted files never reaches an applier, so these can never -// diverge between backends — kept for grid completeness per the plan. +// line ops on one-sided files (Untracked/Added) — line-ops-on-one-sided-files handoff. +// `apply_lines` synthesizes a one-sided (creation) patch for these statuses instead of refusing; +// `partial_hunk_patch`'s doc comment on `src/synthesis.rs` is the mechanism reference. +// --------------------------------------------------------------------------------------------- + +const UNTRACKED_MULTI: &str = "one\ntwo\nthree\nfour\n"; + +fn untracked_multi_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("multi.txt", UNTRACKED_MULTI) + .build() + .expect("fixture build") +} + +fn untracked_partial_stage_contiguous_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let keep_two = line_index(file, 0, LineKind::Addition, "two\n"); + let keep_three = line_index(file, 0, LineKind::Addition, "three\n"); + let sel = LineSelection { + keep_adds: [keep_two, keep_three].into(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn untracked_partial_stage_contiguous_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "multi.txt", + b"two\nthree\n".to_vec(), + )); + // Index-only apply: the untracked worktree file is untouched (still all four lines). + fixture.assert(predicate::repo::workdir_file_equals( + "multi.txt", + UNTRACKED_MULTI.as_bytes().to_vec(), + )); +} + +fn untracked_partial_stage_noncontiguous_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let keep_one = line_index(file, 0, LineKind::Addition, "one\n"); + let keep_three = line_index(file, 0, LineKind::Addition, "three\n"); + let sel = LineSelection { + keep_adds: [keep_one, keep_three].into(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn untracked_partial_stage_noncontiguous_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "multi.txt", + b"one\nthree\n".to_vec(), + )); +} + +fn untracked_partial_discard_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + // Keep (= select for reverse-apply) only "two\n" — the other three lines are dropped, so + // they convert to context (base=New) and survive in the workdir; only "two\n" is removed. + let keep_two = line_index(file, 0, LineKind::Addition, "two\n"); + let sel = LineSelection { + keep_adds: [keep_two].into(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Discard) +} + +fn untracked_partial_discard_verify(fixture: &Fixture) { + let repo = fixture.repo().expect("repo"); + assert!( + repo.workdir().unwrap().join("multi.txt").exists(), + "a partial discard must not remove the file" + ); + fixture.assert(predicate::repo::workdir_file_equals( + "multi.txt", + b"one\nthree\nfour\n".to_vec(), + )); +} + +/// Fork 2's synthesis-level contract, exercised directly (not through `app.rs`'s UI routing, +/// which avoids this shape per the handoff — see `App::discard_selection`'s doc comment): +/// selecting EVERY line for discard renders (once inverted) a whole-file deletion patch applied +/// to the workdir, which removes the file outright rather than leaving it empty. +fn untracked_full_selection_discard_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let sel = LineSelection { + keep_adds: (0..file.hunks[0].lines.len()).collect(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Discard) +} + +fn untracked_full_selection_discard_verify(fixture: &Fixture) { + let repo = fixture.repo().expect("repo"); + assert!( + !repo.workdir().unwrap().join("multi.txt").exists(), + "a full-selection discard must remove the file, not leave it empty" + ); +} + +/// EOFNL (final line missing a trailing newline): untracked files never carry a Deletion line +/// (nothing pre-exists to delete), so the trap-2 splice never applies to them — this scenario +/// documents that directly rather than assuming it. Keeping the final (no-newline) line renders +/// its bytes byte-exact, with a dropped middle line omitted entirely (base=Old). +const UNTRACKED_EOFNL: &str = "one\ntwo\nlast"; // no trailing newline + +fn untracked_eofnl_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("eofnl.txt", UNTRACKED_EOFNL) + .build() + .expect("fixture build") +} + +fn untracked_eofnl_keep_final_line_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let keep_one = line_index(file, 0, LineKind::Addition, "one\n"); + let keep_last = line_index(file, 0, LineKind::Addition, "last"); + let sel = LineSelection { + keep_adds: [keep_one, keep_last].into(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn untracked_eofnl_keep_final_line_verify(fixture: &Fixture) { + // "two\n" dropped entirely (base=Old omits it); "one\n" and "last" (no trailing newline) + // kept, byte-exact. + fixture.assert(predicate::repo::index_blob_equals( + "eofnl.txt", + b"one\nlast".to_vec(), + )); +} + +fn untracked_eofnl_drop_final_line_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let keep_one = line_index(file, 0, LineKind::Addition, "one\n"); + let keep_two = line_index(file, 0, LineKind::Addition, "two\n"); + let sel = LineSelection { + keep_adds: [keep_one, keep_two].into(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn untracked_eofnl_drop_final_line_verify(fixture: &Fixture) { + // The no-newline final line is dropped entirely (base=Old): the result is two normal, + // newline-terminated lines, not a truncated/corrupted tail. + fixture.assert(predicate::repo::index_blob_equals( + "eofnl.txt", + b"one\ntwo\n".to_vec(), + )); +} + +/// Executable untracked file: the synthesized `new file mode` line must carry the real mode +/// (`100755`), not a hardcoded `100644` — the same exec-bit-preserving contract +/// `executable_whole_hunk_stage` pins for Modified files, extended to one-sided creation +/// patches. +fn executable_untracked_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .executable_untracked_file("run.sh", "#!/bin/sh\necho one\necho two\n") + .build() + .expect("fixture build") +} + +fn executable_untracked_partial_stage_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let keep_shebang = line_index(file, 0, LineKind::Addition, "#!/bin/sh\n"); + let keep_one = line_index(file, 0, LineKind::Addition, "echo one\n"); + let sel = LineSelection { + keep_adds: [keep_shebang, keep_one].into(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn executable_untracked_partial_stage_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::index_blob_equals( + "run.sh", + b"#!/bin/sh\necho one\n".to_vec(), + )); + fixture.assert(predicate::repo::has_index_mode("run.sh", 0o100755)); +} + +/// "Stage-rest reaches fully-staged Added": staging the REMAINING lines of an already-partially- +/// staged untracked file must reach the exact same end state `apply_file(Stage)` (the whole-file +/// path) would have produced directly — the one-sided line path and the whole-file path must +/// agree on the fully-staged case, not just diverge less visibly. +fn untracked_stage_rest_reaches_fully_staged_ops( + repo: &Repository, + applier: &dyn Applier, +) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let keep_one = line_index(file, 0, LineKind::Addition, "one\n"); + let sel = LineSelection { + keep_adds: [keep_one].into(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage)?; + + // Re-diff: "one\n" is now Added (staged) and the rest still shows as the untracked + // remainder in the unstaged pane — stage every remaining line. + let diffs = diff_uncommitted(repo)?; + let file = &diffs.unstaged.files[0]; + let sel = LineSelection { + keep_adds: (0..file.hunks[0].lines.len()).collect(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Stage) +} + +fn untracked_stage_rest_reaches_fully_staged_verify(fixture: &Fixture) { + fixture.assert(predicate::repo::has_staged_file("multi.txt")); + fixture.assert(predicate::repo::index_blob_equals( + "multi.txt", + UNTRACKED_MULTI.as_bytes().to_vec(), + )); + let repo = fixture.repo().expect("repo"); + let diffs = diff_uncommitted(repo).expect("diff_uncommitted"); + assert_eq!( + diffs.staged.files[0].status, + FileStatus::Added, + "a fully staged untracked file must show as Added, matching apply_file(Stage)'s result" + ); +} + +/// "Line-unstage of an Added file": the mirror of `unstage_staged_new` (whole-file), but for a +/// line-precise selection — an Added file's line-UNSTAGE (fork 3) reuses the same base=New +/// machinery discard needs. +fn added_multi_build() -> Fixture { + FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("added.txt", UNTRACKED_MULTI) + .build() + .expect("fixture build") +} + +fn added_line_unstage_ops(repo: &Repository, applier: &dyn Applier) -> Result<(), ReviewError> { + let diffs = diff_uncommitted(repo)?; + let file = &diffs.staged.files[0]; + let keep_two = line_index(file, 0, LineKind::Addition, "two\n"); + let sel = LineSelection { + keep_adds: [keep_two].into(), + keep_dels: [].into(), + }; + apply_lines(repo, applier, file, 0, &sel, StageVerb::Unstage) +} + +fn added_line_unstage_verify(fixture: &Fixture) { + // "two\n" is unstaged (removed from the index); the other three lines stay staged. + fixture.assert(predicate::repo::index_blob_equals( + "added.txt", + b"one\nthree\nfour\n".to_vec(), + )); +} + +// --------------------------------------------------------------------------------------------- +// refusals: apply_lines on deleted files never reaches an applier, so this can never diverge +// between backends — kept for grid completeness per the plan. Untracked's own refusal moved to +// `refusal_lines_empty_selection_on_untracked` below: an untracked file no longer refuses line +// ops outright (see the section above), only an EMPTY selection on one still does. // --------------------------------------------------------------------------------------------- -fn refusal_lines_on_untracked_ops( +fn refusal_lines_empty_selection_on_untracked_ops( repo: &Repository, applier: &dyn Applier, ) -> Result<(), ReviewError> { @@ -780,15 +1124,15 @@ fn refusal_lines_on_untracked_ops( matches!( result, Err(ReviewError::Synthesis( - SynthesisError::LineSelectionUnsupported { .. } + SynthesisError::EmptySelection { .. } )) ), - "expected LineSelectionUnsupported, got {result:?}" + "expected EmptySelection, got {result:?}" ); Ok(()) } -fn refusal_lines_on_untracked_verify(fixture: &Fixture) { +fn refusal_lines_empty_selection_on_untracked_verify(fixture: &Fixture) { fixture.assert(predicate::repo::has_untracked_file("new.txt")); } diff --git a/git-workon-review/tests/source.rs b/git-workon-review/tests/suite/source.rs similarity index 100% rename from git-workon-review/tests/source.rs rename to git-workon-review/tests/suite/source.rs diff --git a/git-workon-review/tests/treesitter_smoke.rs b/git-workon-review/tests/suite/treesitter_smoke.rs similarity index 100% rename from git-workon-review/tests/treesitter_smoke.rs rename to git-workon-review/tests/suite/treesitter_smoke.rs